@switchlabs/verify-ai-react-native 2.4.22 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState,
4
4
  import { CameraView, useCameraPermissions, } from 'expo-camera';
5
5
  import { VerifyAIRequestError } from '../client';
6
6
  import { useTelemetry } from '../telemetry/TelemetryContext';
7
+ import { SDK_VERSION } from '../version';
7
8
  import { BikeOverlay } from './BikeOverlay';
8
9
  import { ScooterOverlay } from './ScooterOverlay';
9
10
  import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
@@ -19,8 +20,10 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
19
20
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
20
21
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
21
22
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
22
- const IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
23
- const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
23
+ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
24
+ const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
25
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
26
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
24
27
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
25
28
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
26
29
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -326,11 +329,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
326
329
  telemetryRef.current = telemetry;
327
330
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
328
331
  useEffect(() => {
329
- telemetryRef.current?.track('camera_scanner_mounted', {
332
+ const reporter = telemetryRef.current;
333
+ if (reporter) {
334
+ console.log(`VerifyAI[${SDK_VERSION}]: scanner telemetry attached ` +
335
+ `baseUrl=${reporter.getBaseUrl()} apiKeyPrefix=${reporter.getApiKeyPrefix()}…`);
336
+ }
337
+ else {
338
+ console.warn(`VerifyAI[${SDK_VERSION}]: scanner telemetry is not attached; field ` +
339
+ 'diagnostics will only appear in local device logs. Pass a ' +
340
+ '`TelemetryReporter` into `<VerifyAIScanner telemetry={…} />` or ' +
341
+ 'wrap the tree in `<TelemetryContext.Provider value={reporter}>` ' +
342
+ 'to enable server-side diagnostics.');
343
+ }
344
+ reporter?.track('camera_scanner_mounted', {
330
345
  component: 'scanner',
331
346
  error: 'scanner_mounted',
332
347
  metadata: buildScannerTelemetryMetadataRef.current?.({
333
- scanner_telemetry_attached: telemetryRef.current ? 1 : 0,
348
+ scanner_telemetry_attached: reporter ? 1 : 0,
334
349
  }),
335
350
  });
336
351
  return () => {
@@ -871,7 +886,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
871
886
  cameraReadyRef.current = false;
872
887
  setCameraKey((key) => key + 1);
873
888
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
874
- captureRetryReady = await waitForCameraReady(2500);
889
+ const captureRetryReadyTimeoutMs = Platform.OS === 'android'
890
+ ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
891
+ : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
892
+ captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
875
893
  telemetry?.track('camera_capture_retry', {
876
894
  component: 'scanner',
877
895
  error: normalized,
@@ -881,7 +899,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
881
899
  capture_retry_attempted: captureRetryAttempted,
882
900
  capture_retry_ready: captureRetryReady,
883
901
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
884
- capture_retry_ready_timeout_ms: 2500,
902
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
885
903
  last_native_capture_error: lastNativeCaptureErrorMessage,
886
904
  }),
887
905
  });
@@ -6,7 +6,7 @@ import type { FeatureVector } from './types';
6
6
  /** Ontology class name mapping (must match ontology.ts) */
7
7
  export declare const ONTOLOGY_CLASS_NAMES: Record<number, string>;
8
8
  /** Detection model class index -> ontology class ID mapping. */
9
- export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 20, 21, 22, 23, 24, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
9
+ export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 6, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
10
10
  interface RawDetection {
11
11
  classId: number;
12
12
  confidence: number;
@@ -2,15 +2,22 @@
2
2
  * Feature extractor — converts raw detections into a structured FeatureVector.
3
3
  * Must produce identical output to the Dart and server TypeScript implementations.
4
4
  */
5
- const SCHEMA_VERSION = '1.0.0';
5
+ const SCHEMA_VERSION = '2.0.0';
6
6
  /** Vehicle class IDs (must match ontology.ts) */
7
- const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
7
+ const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
8
8
  /** Surface class IDs */
9
9
  const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
10
10
  /** Ontology class name mapping (must match ontology.ts) */
11
11
  export const ONTOLOGY_CLASS_NAMES = {
12
- 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
12
+ 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
13
13
  20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
14
+ // Car panels (ontology v2 — damage intelligence)
15
+ 25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
16
+ 28: 'car_front_bumper', 29: 'car_rear_bumper',
17
+ 30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
18
+ 34: 'car_fender_fl', 35: 'car_fender_fr',
19
+ 36: 'car_quarter_rl', 37: 'car_quarter_rr',
20
+ 38: 'car_rocker', 39: 'car_mirror',
14
21
  40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
15
22
  44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
16
23
  60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
@@ -20,8 +27,9 @@ export const ONTOLOGY_CLASS_NAMES = {
20
27
  };
21
28
  /** Detection model class index -> ontology class ID mapping. */
22
29
  export const MODEL_OUTPUT_CLASS_IDS = [
23
- 1, 2, 3, 4, 5,
30
+ 1, 2, 3, 4, 5, 6,
24
31
  20, 21, 22, 23, 24,
32
+ 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
25
33
  40, 41, 42, 43, 44, 45, 46, 47, 48,
26
34
  60, 61, 62, 63, 64, 65, 66,
27
35
  80, 81, 82, 83, 84, 85, 86, 87,
@@ -15,8 +15,21 @@ export declare class ModelManager {
15
15
  private _currentBundle;
16
16
  constructor(config: ModelManagerConfig);
17
17
  get currentBundle(): BundleManifest | null;
18
- /** Check for bundle updates from the server. */
19
- checkForUpdates(policyId: string, platform: 'ios' | 'android'): Promise<BundleManifest | null>;
18
+ /**
19
+ * Check for bundle updates from the server.
20
+ *
21
+ * `opts` lets callers narrow which bundle they receive when the server
22
+ * has per-asset / per-region / per-app-version targeted bundles
23
+ * configured (see `app/api/v1/models/latest`). All fields are optional;
24
+ * omitting them keeps the legacy behaviour of receiving the wildcard
25
+ * bundle for the policy.
26
+ */
27
+ checkForUpdates(policyId: string, platform: 'ios' | 'android', opts?: {
28
+ assetType?: string;
29
+ region?: string;
30
+ appVersion?: string;
31
+ deviceId?: string;
32
+ }): Promise<BundleManifest | null>;
20
33
  /** Download model artifacts and verify integrity. */
21
34
  private downloadArtifacts;
22
35
  private readFileSha256;
@@ -26,8 +26,16 @@ export class ModelManager {
26
26
  get currentBundle() {
27
27
  return this._currentBundle;
28
28
  }
29
- /** Check for bundle updates from the server. */
30
- async checkForUpdates(policyId, platform) {
29
+ /**
30
+ * Check for bundle updates from the server.
31
+ *
32
+ * `opts` lets callers narrow which bundle they receive when the server
33
+ * has per-asset / per-region / per-app-version targeted bundles
34
+ * configured (see `app/api/v1/models/latest`). All fields are optional;
35
+ * omitting them keeps the legacy behaviour of receiving the wildcard
36
+ * bundle for the policy.
37
+ */
38
+ async checkForUpdates(policyId, platform, opts) {
31
39
  try {
32
40
  const headers = {
33
41
  'X-API-Key': this.apiKey,
@@ -36,7 +44,16 @@ export class ModelManager {
36
44
  if (this.cachedETag) {
37
45
  headers['If-None-Match'] = this.cachedETag;
38
46
  }
39
- const url = `${this.baseUrl}/models/latest?policy=${policyId}&platform=${platform}`;
47
+ const qs = new URLSearchParams({ policy: policyId, platform });
48
+ if (opts?.assetType)
49
+ qs.set('asset_type', opts.assetType);
50
+ if (opts?.region)
51
+ qs.set('region', opts.region);
52
+ if (opts?.appVersion)
53
+ qs.set('app_version', opts.appVersion);
54
+ if (opts?.deviceId)
55
+ qs.set('device_id', opts.deviceId);
56
+ const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
40
57
  const response = await fetch(url, { headers });
41
58
  if (response.status === 304) {
42
59
  return this._currentBundle;
@@ -5,6 +5,24 @@
5
5
  * This file shares the same logic as lib/verify-ai/ml/policy-engine.ts
6
6
  * but is self-contained for the SDK package.
7
7
  */
8
+ // Severity ordering for `severity_gte`. Must match server TS + Dart impls.
9
+ const SEVERITY_ORDER = ['none', 'light', 'medium', 'severe'];
10
+ function severityRank(s) {
11
+ if (typeof s !== 'string')
12
+ return -1;
13
+ return SEVERITY_ORDER.indexOf(s);
14
+ }
15
+ function compareNumber(a, op, b) {
16
+ switch (op) {
17
+ case 'eq': return a === b;
18
+ case 'neq': return a !== b;
19
+ case 'gt': return a > b;
20
+ case 'gte': return a >= b;
21
+ case 'lt': return a < b;
22
+ case 'lte': return a <= b;
23
+ default: return false;
24
+ }
25
+ }
8
26
  // ─── Field Resolution ───
9
27
  function resolveField(features, field) {
10
28
  const parts = field.split('.');
@@ -67,9 +85,62 @@ function applyOperator(fieldValue, operator, value) {
67
85
  }
68
86
  }
69
87
  function evaluateCondition(features, condition) {
88
+ if (condition.operator === 'severity_gte' ||
89
+ condition.operator === 'panel_has_damage' ||
90
+ condition.operator === 'aggregate_count') {
91
+ return applyDamageOperator(features, condition);
92
+ }
70
93
  const fieldValue = resolveField(features, condition.field);
71
94
  return applyOperator(fieldValue, condition.operator, condition.value);
72
95
  }
96
+ function applyDamageOperator(features, condition) {
97
+ const findings = Array.isArray(features.damage_findings)
98
+ ? features.damage_findings
99
+ : [];
100
+ switch (condition.operator) {
101
+ case 'severity_gte': {
102
+ const minRank = severityRank(condition.value);
103
+ if (minRank < 0)
104
+ return false;
105
+ if (condition.field && condition.field !== 'damage_findings') {
106
+ const f = resolveField(features, condition.field);
107
+ return severityRank(f) >= minRank;
108
+ }
109
+ return findings.some((finding) => severityRank(finding.severity) >= minRank);
110
+ }
111
+ case 'panel_has_damage': {
112
+ const panelName = typeof condition.value === 'string'
113
+ ? condition.value
114
+ : typeof condition.field === 'string' &&
115
+ condition.field !== 'damage_findings' &&
116
+ condition.field !== ''
117
+ ? condition.field
118
+ : null;
119
+ if (!panelName)
120
+ return false;
121
+ return findings.some((finding) => finding.panel === panelName && finding.severity !== 'none');
122
+ }
123
+ case 'aggregate_count': {
124
+ const v = condition.value;
125
+ if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op)
126
+ return false;
127
+ const where = v.where || {};
128
+ const minSeverityRank = where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
129
+ const count = findings.filter((finding) => {
130
+ if (where.panel && finding.panel !== where.panel)
131
+ return false;
132
+ if (where.damage_type && finding.damage_type !== where.damage_type)
133
+ return false;
134
+ if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank)
135
+ return false;
136
+ return true;
137
+ }).length;
138
+ return compareNumber(count, v.op, v.value);
139
+ }
140
+ default:
141
+ return false;
142
+ }
143
+ }
73
144
  function evaluateRule(features, rule) {
74
145
  const passed = rule.conditions.every((c) => evaluateCondition(features, c));
75
146
  return {
package/lib/ml/types.d.ts CHANGED
@@ -32,6 +32,18 @@ export interface ImageQuality {
32
32
  is_dark: boolean;
33
33
  has_vehicle: boolean;
34
34
  }
35
+ export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
36
+ export type DamageType = 'scratch' | 'dent' | 'paint_chip' | 'crack' | 'broken' | 'missing' | 'rust' | 'tear' | 'stain' | 'glass_damage' | 'other';
37
+ export interface DamageFinding {
38
+ finding_id: string;
39
+ panel: string;
40
+ damage_type: DamageType;
41
+ severity: DamageSeverity;
42
+ severity_score: number;
43
+ bbox: [number, number, number, number];
44
+ area_pct: number;
45
+ confidence: number;
46
+ }
35
47
  export interface FeatureVector {
36
48
  schema_version: string;
37
49
  detections: Detection[];
@@ -39,8 +51,12 @@ export interface FeatureVector {
39
51
  image_quality: ImageQuality;
40
52
  vehicle_on_surface: string | null;
41
53
  vehicle_near: string[];
54
+ /** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
55
+ damage_findings?: DamageFinding[];
56
+ /** Panels visible in this frame (schema v2). */
57
+ panel_inventory?: string[];
42
58
  }
43
- export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps';
59
+ export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps' | 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
44
60
  export interface Condition {
45
61
  field: string;
46
62
  operator: ComparisonOperator;
@@ -9,6 +9,12 @@ export declare class TelemetryReporter {
9
9
  private loadedPersisted;
10
10
  private loadPersistedPromise;
11
11
  constructor(apiKey: string, baseUrl: string);
12
+ /** Telemetry POST destination — exposed so the scanner can log an init banner
13
+ * that helps field debugging confirm where events are being sent. */
14
+ getBaseUrl(): string;
15
+ /** First 6 characters of the API key, for log banners. Never returns the full key. */
16
+ getApiKeyPrefix(): string;
17
+ private logDeliveryFailure;
12
18
  /** Track an error event. Fire-and-forget — never throws. */
13
19
  track(eventType: string, opts?: {
14
20
  component?: string;
@@ -40,6 +40,20 @@ export class TelemetryReporter {
40
40
  // Load any persisted events left behind by a previous session
41
41
  this.loadPersistedBuffer();
42
42
  }
43
+ /** Telemetry POST destination — exposed so the scanner can log an init banner
44
+ * that helps field debugging confirm where events are being sent. */
45
+ getBaseUrl() {
46
+ return this.baseUrl;
47
+ }
48
+ /** First 6 characters of the API key, for log banners. Never returns the full key. */
49
+ getApiKeyPrefix() {
50
+ return this.apiKey.length >= 6 ? this.apiKey.slice(0, 6) : this.apiKey;
51
+ }
52
+ logDeliveryFailure(kind, detail, eventCount) {
53
+ const trimmed = detail.length > 200 ? detail.slice(0, 200) : detail;
54
+ console.warn(`VerifyAI[${SDK_VERSION}]: telemetry POST failed kind=${kind} ` +
55
+ `events=${eventCount} url=${this.baseUrl}/telemetry detail=${trimmed}`);
56
+ }
43
57
  /** Track an error event. Fire-and-forget — never throws. */
44
58
  track(eventType, opts = {}) {
45
59
  if (this.disposed)
@@ -113,6 +127,7 @@ export class TelemetryReporter {
113
127
  this.clearFlushTimer();
114
128
  const controller = new AbortController();
115
129
  const timeout = setTimeout(() => controller.abort(), 10000);
130
+ let loggedDeliveryFailure = false;
116
131
  try {
117
132
  const response = await fetch(`${this.baseUrl}/telemetry`, {
118
133
  method: 'POST',
@@ -124,12 +139,26 @@ export class TelemetryReporter {
124
139
  signal: controller.signal,
125
140
  });
126
141
  if (!response.ok) {
142
+ let body = '';
143
+ try {
144
+ body = await response.text();
145
+ }
146
+ catch {
147
+ // ignore
148
+ }
149
+ this.logDeliveryFailure(`http_${response.status}`, body, events.length);
150
+ loggedDeliveryFailure = true;
127
151
  throw new Error(`Telemetry request failed with status ${response.status}`);
128
152
  }
129
153
  // Success — clear persisted buffer since events are now server-side
130
154
  this.persistBuffer(); // buffer is empty at this point, so this clears the key
131
155
  }
132
- catch {
156
+ catch (error) {
157
+ if (!loggedDeliveryFailure) {
158
+ const kind = error instanceof Error ? error.name : typeof error;
159
+ const detail = error instanceof Error ? error.message : String(error);
160
+ this.logDeliveryFailure(kind, detail, events.length);
161
+ }
133
162
  for (const [dedupKey, event] of bufferedEntries) {
134
163
  const existing = this.buffer.get(dedupKey);
135
164
  if (existing) {
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.4.22";
1
+ export declare const SDK_VERSION = "2.5.0";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.22';
1
+ export const SDK_VERSION = '2.5.0';
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.4.22",
3
+ "version": "2.5.0",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/levyelectric/verify.git",
8
+ "directory": "packages/verify-ai-react-native"
9
+ },
5
10
  "main": "./lib/index.js",
6
11
  "types": "./lib/index.d.ts",
7
12
  "exports": {
@@ -23,6 +23,7 @@ import type {
23
23
  import { VerifyAIRequestError } from '../client';
24
24
  import { useTelemetry } from '../telemetry/TelemetryContext';
25
25
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
26
+ import { SDK_VERSION } from '../version';
26
27
  import { BikeOverlay } from './BikeOverlay';
27
28
  import { ScooterOverlay } from './ScooterOverlay';
28
29
  import {
@@ -44,8 +45,10 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
44
45
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
45
46
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
46
47
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
47
- const IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
48
- const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
48
+ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
49
+ const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
50
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
51
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
49
52
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
50
53
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
51
54
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -439,11 +442,26 @@ export function VerifyAIScanner({
439
442
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
440
443
 
441
444
  useEffect(() => {
442
- telemetryRef.current?.track('camera_scanner_mounted', {
445
+ const reporter = telemetryRef.current;
446
+ if (reporter) {
447
+ console.log(
448
+ `VerifyAI[${SDK_VERSION}]: scanner telemetry attached ` +
449
+ `baseUrl=${reporter.getBaseUrl()} apiKeyPrefix=${reporter.getApiKeyPrefix()}…`,
450
+ );
451
+ } else {
452
+ console.warn(
453
+ `VerifyAI[${SDK_VERSION}]: scanner telemetry is not attached; field ` +
454
+ 'diagnostics will only appear in local device logs. Pass a ' +
455
+ '`TelemetryReporter` into `<VerifyAIScanner telemetry={…} />` or ' +
456
+ 'wrap the tree in `<TelemetryContext.Provider value={reporter}>` ' +
457
+ 'to enable server-side diagnostics.',
458
+ );
459
+ }
460
+ reporter?.track('camera_scanner_mounted', {
443
461
  component: 'scanner',
444
462
  error: 'scanner_mounted',
445
463
  metadata: buildScannerTelemetryMetadataRef.current?.({
446
- scanner_telemetry_attached: telemetryRef.current ? 1 : 0,
464
+ scanner_telemetry_attached: reporter ? 1 : 0,
447
465
  }),
448
466
  });
449
467
 
@@ -1063,7 +1081,10 @@ export function VerifyAIScanner({
1063
1081
  cameraReadyRef.current = false;
1064
1082
  setCameraKey((key) => key + 1);
1065
1083
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1066
- captureRetryReady = await waitForCameraReady(2500);
1084
+ const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1085
+ ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1086
+ : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
1087
+ captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
1067
1088
  telemetry?.track('camera_capture_retry', {
1068
1089
  component: 'scanner',
1069
1090
  error: normalized,
@@ -1073,7 +1094,7 @@ export function VerifyAIScanner({
1073
1094
  capture_retry_attempted: captureRetryAttempted,
1074
1095
  capture_retry_ready: captureRetryReady,
1075
1096
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1076
- capture_retry_ready_timeout_ms: 2500,
1097
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1077
1098
  last_native_capture_error: lastNativeCaptureErrorMessage,
1078
1099
  }),
1079
1100
  });
@@ -5,17 +5,24 @@
5
5
 
6
6
  import type { FeatureVector, Detection, ImageQuality } from './types';
7
7
 
8
- const SCHEMA_VERSION = '1.0.0';
8
+ const SCHEMA_VERSION = '2.0.0';
9
9
 
10
10
  /** Vehicle class IDs (must match ontology.ts) */
11
- const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
11
+ const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
12
12
  /** Surface class IDs */
13
13
  const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
14
14
 
15
15
  /** Ontology class name mapping (must match ontology.ts) */
16
16
  export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
17
- 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
17
+ 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
18
18
  20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
19
+ // Car panels (ontology v2 — damage intelligence)
20
+ 25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
21
+ 28: 'car_front_bumper', 29: 'car_rear_bumper',
22
+ 30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
23
+ 34: 'car_fender_fl', 35: 'car_fender_fr',
24
+ 36: 'car_quarter_rl', 37: 'car_quarter_rr',
25
+ 38: 'car_rocker', 39: 'car_mirror',
19
26
  40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
20
27
  44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
21
28
  60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
@@ -26,8 +33,9 @@ export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
26
33
 
27
34
  /** Detection model class index -> ontology class ID mapping. */
28
35
  export const MODEL_OUTPUT_CLASS_IDS = [
29
- 1, 2, 3, 4, 5,
36
+ 1, 2, 3, 4, 5, 6,
30
37
  20, 21, 22, 23, 24,
38
+ 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
31
39
  40, 41, 42, 43, 44, 45, 46, 47, 48,
32
40
  60, 61, 62, 63, 64, 65, 66,
33
41
  80, 81, 82, 83, 84, 85, 86, 87,
@@ -62,8 +62,25 @@ export class ModelManager {
62
62
  return this._currentBundle;
63
63
  }
64
64
 
65
- /** Check for bundle updates from the server. */
66
- async checkForUpdates(policyId: string, platform: 'ios' | 'android'): Promise<BundleManifest | null> {
65
+ /**
66
+ * Check for bundle updates from the server.
67
+ *
68
+ * `opts` lets callers narrow which bundle they receive when the server
69
+ * has per-asset / per-region / per-app-version targeted bundles
70
+ * configured (see `app/api/v1/models/latest`). All fields are optional;
71
+ * omitting them keeps the legacy behaviour of receiving the wildcard
72
+ * bundle for the policy.
73
+ */
74
+ async checkForUpdates(
75
+ policyId: string,
76
+ platform: 'ios' | 'android',
77
+ opts?: {
78
+ assetType?: string;
79
+ region?: string;
80
+ appVersion?: string;
81
+ deviceId?: string;
82
+ },
83
+ ): Promise<BundleManifest | null> {
67
84
  try {
68
85
  const headers: Record<string, string> = {
69
86
  'X-API-Key': this.apiKey,
@@ -74,7 +91,13 @@ export class ModelManager {
74
91
  headers['If-None-Match'] = this.cachedETag;
75
92
  }
76
93
 
77
- const url = `${this.baseUrl}/models/latest?policy=${policyId}&platform=${platform}`;
94
+ const qs = new URLSearchParams({ policy: policyId, platform });
95
+ if (opts?.assetType) qs.set('asset_type', opts.assetType);
96
+ if (opts?.region) qs.set('region', opts.region);
97
+ if (opts?.appVersion) qs.set('app_version', opts.appVersion);
98
+ if (opts?.deviceId) qs.set('device_id', opts.deviceId);
99
+
100
+ const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
78
101
  const response = await fetch(url, { headers });
79
102
 
80
103
  if (response.status === 304) {
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import type {
10
+ DamageFinding,
11
+ DamageSeverity,
10
12
  FeatureVector,
11
13
  PolicyAST,
12
14
  PolicyResult,
@@ -17,6 +19,35 @@ import type {
17
19
  ComparisonOperator,
18
20
  } from './types';
19
21
 
22
+ // Severity ordering for `severity_gte`. Must match server TS + Dart impls.
23
+ const SEVERITY_ORDER: DamageSeverity[] = ['none', 'light', 'medium', 'severe'];
24
+ function severityRank(s: unknown): number {
25
+ if (typeof s !== 'string') return -1;
26
+ return SEVERITY_ORDER.indexOf(s as DamageSeverity);
27
+ }
28
+
29
+ interface AggregateCountValue {
30
+ op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte';
31
+ value: number;
32
+ where?: {
33
+ panel?: string;
34
+ damage_type?: string;
35
+ severity_gte?: DamageSeverity;
36
+ };
37
+ }
38
+
39
+ function compareNumber(a: number, op: AggregateCountValue['op'], b: number): boolean {
40
+ switch (op) {
41
+ case 'eq': return a === b;
42
+ case 'neq': return a !== b;
43
+ case 'gt': return a > b;
44
+ case 'gte': return a >= b;
45
+ case 'lt': return a < b;
46
+ case 'lte': return a <= b;
47
+ default: return false;
48
+ }
49
+ }
50
+
20
51
  // ─── Field Resolution ───
21
52
 
22
53
  function resolveField(features: FeatureVector, field: string): unknown {
@@ -77,10 +108,65 @@ function applyOperator(fieldValue: unknown, operator: ComparisonOperator, value:
77
108
  }
78
109
 
79
110
  function evaluateCondition(features: FeatureVector, condition: Condition): boolean {
111
+ if (
112
+ condition.operator === 'severity_gte' ||
113
+ condition.operator === 'panel_has_damage' ||
114
+ condition.operator === 'aggregate_count'
115
+ ) {
116
+ return applyDamageOperator(features, condition);
117
+ }
80
118
  const fieldValue = resolveField(features, condition.field);
81
119
  return applyOperator(fieldValue, condition.operator, condition.value);
82
120
  }
83
121
 
122
+ function applyDamageOperator(features: FeatureVector, condition: Condition): boolean {
123
+ const findings: DamageFinding[] = Array.isArray(features.damage_findings)
124
+ ? features.damage_findings
125
+ : [];
126
+
127
+ switch (condition.operator) {
128
+ case 'severity_gte': {
129
+ const minRank = severityRank(condition.value);
130
+ if (minRank < 0) return false;
131
+ if (condition.field && condition.field !== 'damage_findings') {
132
+ const f = resolveField(features, condition.field);
133
+ return severityRank(f) >= minRank;
134
+ }
135
+ return findings.some((finding) => severityRank(finding.severity) >= minRank);
136
+ }
137
+ case 'panel_has_damage': {
138
+ const panelName =
139
+ typeof condition.value === 'string'
140
+ ? condition.value
141
+ : typeof condition.field === 'string' &&
142
+ condition.field !== 'damage_findings' &&
143
+ condition.field !== ''
144
+ ? condition.field
145
+ : null;
146
+ if (!panelName) return false;
147
+ return findings.some(
148
+ (finding) => finding.panel === panelName && finding.severity !== 'none',
149
+ );
150
+ }
151
+ case 'aggregate_count': {
152
+ const v = condition.value as AggregateCountValue | null | undefined;
153
+ if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op) return false;
154
+ const where = v.where || {};
155
+ const minSeverityRank =
156
+ where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
157
+ const count = findings.filter((finding) => {
158
+ if (where.panel && finding.panel !== where.panel) return false;
159
+ if (where.damage_type && finding.damage_type !== where.damage_type) return false;
160
+ if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank) return false;
161
+ return true;
162
+ }).length;
163
+ return compareNumber(count, v.op, v.value);
164
+ }
165
+ default:
166
+ return false;
167
+ }
168
+ }
169
+
84
170
  function evaluateRule(features: FeatureVector, rule: Rule): RuleResult {
85
171
  const passed = rule.conditions.every((c) => evaluateCondition(features, c));
86
172
  return {
package/src/ml/types.ts CHANGED
@@ -39,6 +39,33 @@ export interface ImageQuality {
39
39
  has_vehicle: boolean;
40
40
  }
41
41
 
42
+ // ─── Damage Findings (schema v2) ───
43
+
44
+ export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
45
+ export type DamageType =
46
+ | 'scratch'
47
+ | 'dent'
48
+ | 'paint_chip'
49
+ | 'crack'
50
+ | 'broken'
51
+ | 'missing'
52
+ | 'rust'
53
+ | 'tear'
54
+ | 'stain'
55
+ | 'glass_damage'
56
+ | 'other';
57
+
58
+ export interface DamageFinding {
59
+ finding_id: string;
60
+ panel: string;
61
+ damage_type: DamageType;
62
+ severity: DamageSeverity;
63
+ severity_score: number;
64
+ bbox: [number, number, number, number];
65
+ area_pct: number;
66
+ confidence: number;
67
+ }
68
+
42
69
  export interface FeatureVector {
43
70
  schema_version: string;
44
71
  detections: Detection[];
@@ -46,6 +73,10 @@ export interface FeatureVector {
46
73
  image_quality: ImageQuality;
47
74
  vehicle_on_surface: string | null;
48
75
  vehicle_near: string[];
76
+ /** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
77
+ damage_findings?: DamageFinding[];
78
+ /** Panels visible in this frame (schema v2). */
79
+ panel_inventory?: string[];
49
80
  }
50
81
 
51
82
  // ─── Policy AST Types ───
@@ -54,7 +85,9 @@ export type ComparisonOperator =
54
85
  | 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
55
86
  | 'contains' | 'not_contains'
56
87
  | 'exists' | 'not_exists'
57
- | 'in' | 'not_in' | 'overlaps';
88
+ | 'in' | 'not_in' | 'overlaps'
89
+ // AST v1.1 — damage operators
90
+ | 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
58
91
 
59
92
  export interface Condition {
60
93
  field: string;
@@ -71,6 +71,25 @@ export class TelemetryReporter {
71
71
  this.loadPersistedBuffer();
72
72
  }
73
73
 
74
+ /** Telemetry POST destination — exposed so the scanner can log an init banner
75
+ * that helps field debugging confirm where events are being sent. */
76
+ getBaseUrl(): string {
77
+ return this.baseUrl;
78
+ }
79
+
80
+ /** First 6 characters of the API key, for log banners. Never returns the full key. */
81
+ getApiKeyPrefix(): string {
82
+ return this.apiKey.length >= 6 ? this.apiKey.slice(0, 6) : this.apiKey;
83
+ }
84
+
85
+ private logDeliveryFailure(kind: string, detail: string, eventCount: number): void {
86
+ const trimmed = detail.length > 200 ? detail.slice(0, 200) : detail;
87
+ console.warn(
88
+ `VerifyAI[${SDK_VERSION}]: telemetry POST failed kind=${kind} ` +
89
+ `events=${eventCount} url=${this.baseUrl}/telemetry detail=${trimmed}`,
90
+ );
91
+ }
92
+
74
93
  /** Track an error event. Fire-and-forget — never throws. */
75
94
  track(
76
95
  eventType: string,
@@ -158,6 +177,7 @@ export class TelemetryReporter {
158
177
  const controller = new AbortController();
159
178
  const timeout = setTimeout(() => controller.abort(), 10000);
160
179
 
180
+ let loggedDeliveryFailure = false;
161
181
  try {
162
182
  const response = await fetch(`${this.baseUrl}/telemetry`, {
163
183
  method: 'POST',
@@ -170,12 +190,25 @@ export class TelemetryReporter {
170
190
  });
171
191
 
172
192
  if (!response.ok) {
193
+ let body = '';
194
+ try {
195
+ body = await response.text();
196
+ } catch {
197
+ // ignore
198
+ }
199
+ this.logDeliveryFailure(`http_${response.status}`, body, events.length);
200
+ loggedDeliveryFailure = true;
173
201
  throw new Error(`Telemetry request failed with status ${response.status}`);
174
202
  }
175
203
 
176
204
  // Success — clear persisted buffer since events are now server-side
177
205
  this.persistBuffer(); // buffer is empty at this point, so this clears the key
178
- } catch {
206
+ } catch (error) {
207
+ if (!loggedDeliveryFailure) {
208
+ const kind = error instanceof Error ? error.name : typeof error;
209
+ const detail = error instanceof Error ? error.message : String(error);
210
+ this.logDeliveryFailure(kind, detail, events.length);
211
+ }
179
212
  for (const [dedupKey, event] of bufferedEntries) {
180
213
  const existing = this.buffer.get(dedupKey);
181
214
  if (existing) {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.22';
1
+ export const SDK_VERSION = '2.5.0';