@blueharford/scrypted-spatial-awareness 0.1.15 → 0.2.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.
@@ -10,8 +10,11 @@ import sdk, {
10
10
  ObjectDetectionResult,
11
11
  ScryptedInterface,
12
12
  EventListenerRegister,
13
+ ObjectDetection,
14
+ Camera,
15
+ MediaObject,
13
16
  } from '@scrypted/sdk';
14
- import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
17
+ import { CameraTopology, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
15
18
  import {
16
19
  TrackedObject,
17
20
  ObjectSighting,
@@ -22,6 +25,11 @@ import {
22
25
  import { TrackingState } from '../state/tracking-state';
23
26
  import { AlertManager } from '../alerts/alert-manager';
24
27
  import { ObjectCorrelator } from './object-correlator';
28
+ import {
29
+ SpatialReasoningEngine,
30
+ SpatialReasoningConfig,
31
+ SpatialReasoningResult,
32
+ } from './spatial-reasoning';
25
33
 
26
34
  const { systemManager } = sdk;
27
35
 
@@ -34,6 +42,16 @@ export interface TrackingEngineConfig {
34
42
  lostTimeout: number;
35
43
  /** Enable visual embedding matching */
36
44
  useVisualMatching: boolean;
45
+ /** Loitering threshold - object must be visible this long before alerting (ms) */
46
+ loiteringThreshold: number;
47
+ /** Per-object alert cooldown (ms) */
48
+ objectAlertCooldown: number;
49
+ /** Use LLM for enhanced descriptions */
50
+ useLlmDescriptions: boolean;
51
+ /** Enable landmark learning from AI */
52
+ enableLandmarkLearning?: boolean;
53
+ /** Minimum confidence for landmark suggestions */
54
+ landmarkConfidenceThreshold?: number;
37
55
  }
38
56
 
39
57
  export class TrackingEngine {
@@ -43,9 +61,14 @@ export class TrackingEngine {
43
61
  private config: TrackingEngineConfig;
44
62
  private console: Console;
45
63
  private correlator: ObjectCorrelator;
64
+ private spatialReasoning: SpatialReasoningEngine;
46
65
  private listeners: Map<string, EventListenerRegister> = new Map();
47
66
  private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
48
67
  private lostCheckInterval: NodeJS.Timeout | null = null;
68
+ /** Track last alert time per object to enforce cooldown */
69
+ private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
70
+ /** Callback for topology changes (e.g., landmark suggestions) */
71
+ private onTopologyChange?: (topology: CameraTopology) => void;
49
72
 
50
73
  constructor(
51
74
  topology: CameraTopology,
@@ -60,6 +83,21 @@ export class TrackingEngine {
60
83
  this.config = config;
61
84
  this.console = console;
62
85
  this.correlator = new ObjectCorrelator(topology, config);
86
+
87
+ // Initialize spatial reasoning engine
88
+ const spatialConfig: SpatialReasoningConfig = {
89
+ enableLlm: config.useLlmDescriptions,
90
+ enableLandmarkLearning: config.enableLandmarkLearning ?? true,
91
+ landmarkConfidenceThreshold: config.landmarkConfidenceThreshold ?? 0.7,
92
+ contextCacheTtl: 60000, // 1 minute cache
93
+ };
94
+ this.spatialReasoning = new SpatialReasoningEngine(spatialConfig, console);
95
+ this.spatialReasoning.updateTopology(topology);
96
+ }
97
+
98
+ /** Set callback for topology changes */
99
+ setTopologyChangeCallback(callback: (topology: CameraTopology) => void): void {
100
+ this.onTopologyChange = callback;
63
101
  }
64
102
 
65
103
  /** Start listening to all cameras in topology */
@@ -176,6 +214,90 @@ export class TrackingEngine {
176
214
  }
177
215
  }
178
216
 
217
+ /** Check if object passes loitering threshold */
218
+ private passesLoiteringThreshold(tracked: TrackedObject): boolean {
219
+ const visibleDuration = tracked.lastSeen - tracked.firstSeen;
220
+ return visibleDuration >= this.config.loiteringThreshold;
221
+ }
222
+
223
+ /** Check if object is in alert cooldown */
224
+ private isInAlertCooldown(globalId: GlobalTrackingId): boolean {
225
+ const lastAlertTime = this.objectLastAlertTime.get(globalId);
226
+ if (!lastAlertTime) return false;
227
+ return (Date.now() - lastAlertTime) < this.config.objectAlertCooldown;
228
+ }
229
+
230
+ /** Record that we alerted for this object */
231
+ private recordAlertTime(globalId: GlobalTrackingId): void {
232
+ this.objectLastAlertTime.set(globalId, Date.now());
233
+ }
234
+
235
+ /** Get spatial reasoning result for movement (uses RAG + LLM) */
236
+ private async getSpatialDescription(
237
+ tracked: TrackedObject,
238
+ fromCameraId: string,
239
+ toCameraId: string,
240
+ transitTime: number,
241
+ currentCameraId: string
242
+ ): Promise<SpatialReasoningResult | null> {
243
+ try {
244
+ // Get snapshot from camera for LLM analysis (if LLM is enabled)
245
+ let mediaObject: MediaObject | undefined;
246
+ if (this.config.useLlmDescriptions) {
247
+ const camera = systemManager.getDeviceById<Camera>(currentCameraId);
248
+ if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
249
+ mediaObject = await camera.takePicture();
250
+ }
251
+ }
252
+
253
+ // Use spatial reasoning engine for rich context-aware description
254
+ const result = await this.spatialReasoning.generateMovementDescription(
255
+ tracked,
256
+ fromCameraId,
257
+ toCameraId,
258
+ transitTime,
259
+ mediaObject
260
+ );
261
+
262
+ // Optionally trigger landmark learning
263
+ if (this.config.enableLandmarkLearning && mediaObject) {
264
+ this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
265
+ }
266
+
267
+ return result;
268
+ } catch (e) {
269
+ this.console.warn('Spatial reasoning failed:', e);
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /** Try to learn new landmarks from detections (background task) */
275
+ private async tryLearnLandmark(
276
+ cameraId: string,
277
+ mediaObject: MediaObject,
278
+ objectClass: string
279
+ ): Promise<void> {
280
+ try {
281
+ // Position is approximate - could be improved with object position from detection
282
+ const position = { x: 50, y: 50 };
283
+ const suggestion = await this.spatialReasoning.suggestLandmark(
284
+ cameraId,
285
+ mediaObject,
286
+ objectClass,
287
+ position
288
+ );
289
+
290
+ if (suggestion) {
291
+ this.console.log(
292
+ `AI suggested landmark: ${suggestion.landmark.name} ` +
293
+ `(${suggestion.landmark.type}, confidence: ${suggestion.landmark.aiConfidence?.toFixed(2)})`
294
+ );
295
+ }
296
+ } catch (e) {
297
+ // Landmark learning is best-effort, don't log errors
298
+ }
299
+ }
300
+
179
301
  /** Process a single sighting */
180
302
  private async processSighting(
181
303
  sighting: ObjectSighting,
@@ -212,17 +334,35 @@ export class TrackingEngine {
212
334
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
213
335
  );
214
336
 
215
- // Generate movement alert for cross-camera transition
216
- await this.alertManager.checkAndAlert('movement', tracked, {
217
- fromCameraId: lastSighting.cameraId,
218
- fromCameraName: lastSighting.cameraName,
219
- toCameraId: sighting.cameraId,
220
- toCameraName: sighting.cameraName,
221
- transitTime: transitDuration,
222
- objectClass: sighting.detection.className,
223
- objectLabel: sighting.detection.label,
224
- detectionId: sighting.detectionId,
225
- });
337
+ // Check loitering threshold and per-object cooldown before alerting
338
+ if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
339
+ // Get spatial reasoning result with RAG context
340
+ const spatialResult = await this.getSpatialDescription(
341
+ tracked,
342
+ lastSighting.cameraId,
343
+ sighting.cameraId,
344
+ transitDuration,
345
+ sighting.cameraId
346
+ );
347
+
348
+ // Generate movement alert for cross-camera transition
349
+ await this.alertManager.checkAndAlert('movement', tracked, {
350
+ fromCameraId: lastSighting.cameraId,
351
+ fromCameraName: lastSighting.cameraName,
352
+ toCameraId: sighting.cameraId,
353
+ toCameraName: sighting.cameraName,
354
+ transitTime: transitDuration,
355
+ objectClass: sighting.detection.className,
356
+ objectLabel: spatialResult?.description || sighting.detection.label,
357
+ detectionId: sighting.detectionId,
358
+ // Include spatial context for enriched alerts
359
+ pathDescription: spatialResult?.pathDescription,
360
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
361
+ usedLlm: spatialResult?.usedLlm,
362
+ });
363
+
364
+ this.recordAlertTime(tracked.globalId);
365
+ }
226
366
  }
227
367
 
228
368
  // Add sighting to tracked object
@@ -253,14 +393,28 @@ export class TrackingEngine {
253
393
  );
254
394
 
255
395
  // Generate entry alert if this is an entry point
256
- if (isEntryPoint) {
396
+ // Entry alerts also respect loitering threshold and cooldown
397
+ if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
398
+ // Get spatial reasoning for entry event
399
+ const spatialResult = await this.getSpatialDescription(
400
+ tracked,
401
+ 'outside', // Virtual "outside" location for entry
402
+ sighting.cameraId,
403
+ 0,
404
+ sighting.cameraId
405
+ );
406
+
257
407
  await this.alertManager.checkAndAlert('property_entry', tracked, {
258
408
  cameraId: sighting.cameraId,
259
409
  cameraName: sighting.cameraName,
260
410
  objectClass: sighting.detection.className,
261
- objectLabel: sighting.detection.label,
411
+ objectLabel: spatialResult?.description || sighting.detection.label,
262
412
  detectionId: sighting.detectionId,
413
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
414
+ usedLlm: spatialResult?.usedLlm,
263
415
  });
416
+
417
+ this.recordAlertTime(globalId);
264
418
  }
265
419
  }
266
420
  }
@@ -362,6 +516,45 @@ export class TrackingEngine {
362
516
  updateTopology(topology: CameraTopology): void {
363
517
  this.topology = topology;
364
518
  this.correlator = new ObjectCorrelator(topology, this.config);
519
+ this.spatialReasoning.updateTopology(topology);
520
+ }
521
+
522
+ /** Get pending landmark suggestions */
523
+ getPendingLandmarkSuggestions(): import('../models/topology').LandmarkSuggestion[] {
524
+ return this.spatialReasoning.getPendingSuggestions();
525
+ }
526
+
527
+ /** Accept a landmark suggestion, adding it to topology */
528
+ acceptLandmarkSuggestion(suggestionId: string): Landmark | null {
529
+ const landmark = this.spatialReasoning.acceptSuggestion(suggestionId);
530
+ if (landmark && this.topology) {
531
+ // Add the accepted landmark to topology
532
+ if (!this.topology.landmarks) {
533
+ this.topology.landmarks = [];
534
+ }
535
+ this.topology.landmarks.push(landmark);
536
+
537
+ // Notify about topology change
538
+ if (this.onTopologyChange) {
539
+ this.onTopologyChange(this.topology);
540
+ }
541
+ }
542
+ return landmark;
543
+ }
544
+
545
+ /** Reject a landmark suggestion */
546
+ rejectLandmarkSuggestion(suggestionId: string): boolean {
547
+ return this.spatialReasoning.rejectSuggestion(suggestionId);
548
+ }
549
+
550
+ /** Get landmark templates for UI */
551
+ getLandmarkTemplates(): typeof import('../models/topology').LANDMARK_TEMPLATES {
552
+ return this.spatialReasoning.getLandmarkTemplates();
553
+ }
554
+
555
+ /** Get the spatial reasoning engine for direct access */
556
+ getSpatialReasoningEngine(): SpatialReasoningEngine {
557
+ return this.spatialReasoning;
365
558
  }
366
559
 
367
560
  /** Get current topology */
package/src/main.ts CHANGED
@@ -15,7 +15,14 @@ import sdk, {
15
15
  Readme,
16
16
  } from '@scrypted/sdk';
17
17
  import { StorageSettings } from '@scrypted/sdk/storage-settings';
18
- import { CameraTopology, createEmptyTopology } from './models/topology';
18
+ import {
19
+ CameraTopology,
20
+ createEmptyTopology,
21
+ Landmark,
22
+ LandmarkSuggestion,
23
+ LANDMARK_TEMPLATES,
24
+ inferRelationships,
25
+ } from './models/topology';
19
26
  import { TrackedObject } from './models/tracked-object';
20
27
  import { Alert, AlertRule, createDefaultRules } from './models/alert';
21
28
  import { TrackingState } from './state/tracking-state';
@@ -85,6 +92,43 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
85
92
  description: 'Use visual embeddings for object correlation (requires compatible detectors)',
86
93
  group: 'Tracking',
87
94
  },
95
+ loiteringThreshold: {
96
+ title: 'Loitering Threshold (seconds)',
97
+ type: 'number',
98
+ defaultValue: 3,
99
+ description: 'Object must be visible for this duration before triggering movement alerts',
100
+ group: 'Tracking',
101
+ },
102
+ objectAlertCooldown: {
103
+ title: 'Per-Object Alert Cooldown (seconds)',
104
+ type: 'number',
105
+ defaultValue: 30,
106
+ description: 'Minimum time between alerts for the same tracked object',
107
+ group: 'Tracking',
108
+ },
109
+
110
+ // LLM Integration
111
+ useLlmDescriptions: {
112
+ title: 'Use LLM for Rich Descriptions',
113
+ type: 'boolean',
114
+ defaultValue: true,
115
+ description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
116
+ group: 'AI & Spatial Reasoning',
117
+ },
118
+ enableLandmarkLearning: {
119
+ title: 'Learn Landmarks from AI',
120
+ type: 'boolean',
121
+ defaultValue: true,
122
+ description: 'Allow AI to suggest new landmarks based on detected objects and camera context',
123
+ group: 'AI & Spatial Reasoning',
124
+ },
125
+ landmarkConfidenceThreshold: {
126
+ title: 'Landmark Suggestion Confidence',
127
+ type: 'number',
128
+ defaultValue: 0.7,
129
+ description: 'Minimum AI confidence (0-1) to suggest a landmark',
130
+ group: 'AI & Spatial Reasoning',
131
+ },
88
132
 
89
133
  // MQTT Settings
90
134
  enableMqtt: {
@@ -244,6 +288,11 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
244
288
  correlationThreshold: this.storageSettings.values.correlationThreshold as number || 0.6,
245
289
  lostTimeout: (this.storageSettings.values.lostTimeout as number || 300) * 1000,
246
290
  useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
291
+ loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
292
+ objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
293
+ useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
294
+ enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning as boolean ?? true,
295
+ landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold as number ?? 0.7,
247
296
  };
248
297
 
249
298
  this.trackingEngine = new TrackingEngine(
@@ -254,6 +303,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
254
303
  this.console
255
304
  );
256
305
 
306
+ // Set up callback to save topology changes (e.g., from accepted landmark suggestions)
307
+ this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
308
+ this.storage.setItem('topology', JSON.stringify(updatedTopology));
309
+ this.console.log('Topology auto-saved after change');
310
+ });
311
+
257
312
  await this.trackingEngine.startTracking();
258
313
  this.console.log('Tracking engine started');
259
314
  }
@@ -517,7 +572,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
517
572
  key === 'correlationWindow' ||
518
573
  key === 'correlationThreshold' ||
519
574
  key === 'lostTimeout' ||
520
- key === 'useVisualMatching'
575
+ key === 'useVisualMatching' ||
576
+ key === 'loiteringThreshold' ||
577
+ key === 'objectAlertCooldown' ||
578
+ key === 'useLlmDescriptions' ||
579
+ key === 'enableLandmarkLearning' ||
580
+ key === 'landmarkConfidenceThreshold'
521
581
  ) {
522
582
  const topologyJson = this.storage.getItem('topology');
523
583
  if (topologyJson) {
@@ -580,6 +640,34 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
580
640
  return this.handleFloorPlanRequest(request, response);
581
641
  }
582
642
 
643
+ if (path.endsWith('/api/landmarks')) {
644
+ return this.handleLandmarksRequest(request, response);
645
+ }
646
+
647
+ if (path.match(/\/api\/landmarks\/[\w-]+$/)) {
648
+ const landmarkId = path.split('/').pop()!;
649
+ return this.handleLandmarkRequest(landmarkId, request, response);
650
+ }
651
+
652
+ if (path.endsWith('/api/landmark-suggestions')) {
653
+ return this.handleLandmarkSuggestionsRequest(request, response);
654
+ }
655
+
656
+ if (path.match(/\/api\/landmark-suggestions\/[\w-]+\/(accept|reject)$/)) {
657
+ const parts = path.split('/');
658
+ const action = parts.pop()!;
659
+ const suggestionId = parts.pop()!;
660
+ return this.handleSuggestionActionRequest(suggestionId, action, response);
661
+ }
662
+
663
+ if (path.endsWith('/api/landmark-templates')) {
664
+ return this.handleLandmarkTemplatesRequest(response);
665
+ }
666
+
667
+ if (path.endsWith('/api/infer-relationships')) {
668
+ return this.handleInferRelationshipsRequest(response);
669
+ }
670
+
583
671
  // UI Routes
584
672
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
585
673
  return this.serveEditorUI(response);
@@ -762,6 +850,210 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
762
850
  }
763
851
  }
764
852
 
853
+ private handleLandmarksRequest(request: HttpRequest, response: HttpResponse): void {
854
+ const topology = this.getTopology();
855
+ if (!topology) {
856
+ response.send(JSON.stringify({ landmarks: [] }), {
857
+ headers: { 'Content-Type': 'application/json' },
858
+ });
859
+ return;
860
+ }
861
+
862
+ if (request.method === 'GET') {
863
+ response.send(JSON.stringify({
864
+ landmarks: topology.landmarks || [],
865
+ }), {
866
+ headers: { 'Content-Type': 'application/json' },
867
+ });
868
+ } else if (request.method === 'POST') {
869
+ try {
870
+ const landmark = JSON.parse(request.body!) as Landmark;
871
+ if (!landmark.id) {
872
+ landmark.id = `landmark_${Date.now()}`;
873
+ }
874
+ if (!topology.landmarks) {
875
+ topology.landmarks = [];
876
+ }
877
+ topology.landmarks.push(landmark);
878
+ this.storage.setItem('topology', JSON.stringify(topology));
879
+ if (this.trackingEngine) {
880
+ this.trackingEngine.updateTopology(topology);
881
+ }
882
+ response.send(JSON.stringify({ success: true, landmark }), {
883
+ headers: { 'Content-Type': 'application/json' },
884
+ });
885
+ } catch (e) {
886
+ response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
887
+ code: 400,
888
+ headers: { 'Content-Type': 'application/json' },
889
+ });
890
+ }
891
+ }
892
+ }
893
+
894
+ private handleLandmarkRequest(
895
+ landmarkId: string,
896
+ request: HttpRequest,
897
+ response: HttpResponse
898
+ ): void {
899
+ const topology = this.getTopology();
900
+ if (!topology) {
901
+ response.send(JSON.stringify({ error: 'No topology configured' }), {
902
+ code: 404,
903
+ headers: { 'Content-Type': 'application/json' },
904
+ });
905
+ return;
906
+ }
907
+
908
+ const landmarkIndex = topology.landmarks?.findIndex(l => l.id === landmarkId) ?? -1;
909
+
910
+ if (request.method === 'GET') {
911
+ const landmark = topology.landmarks?.[landmarkIndex];
912
+ if (landmark) {
913
+ response.send(JSON.stringify(landmark), {
914
+ headers: { 'Content-Type': 'application/json' },
915
+ });
916
+ } else {
917
+ response.send(JSON.stringify({ error: 'Landmark not found' }), {
918
+ code: 404,
919
+ headers: { 'Content-Type': 'application/json' },
920
+ });
921
+ }
922
+ } else if (request.method === 'PUT') {
923
+ try {
924
+ const updates = JSON.parse(request.body!) as Partial<Landmark>;
925
+ if (landmarkIndex >= 0) {
926
+ topology.landmarks![landmarkIndex] = {
927
+ ...topology.landmarks![landmarkIndex],
928
+ ...updates,
929
+ id: landmarkId, // Preserve ID
930
+ };
931
+ this.storage.setItem('topology', JSON.stringify(topology));
932
+ if (this.trackingEngine) {
933
+ this.trackingEngine.updateTopology(topology);
934
+ }
935
+ response.send(JSON.stringify({ success: true, landmark: topology.landmarks![landmarkIndex] }), {
936
+ headers: { 'Content-Type': 'application/json' },
937
+ });
938
+ } else {
939
+ response.send(JSON.stringify({ error: 'Landmark not found' }), {
940
+ code: 404,
941
+ headers: { 'Content-Type': 'application/json' },
942
+ });
943
+ }
944
+ } catch (e) {
945
+ response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
946
+ code: 400,
947
+ headers: { 'Content-Type': 'application/json' },
948
+ });
949
+ }
950
+ } else if (request.method === 'DELETE') {
951
+ if (landmarkIndex >= 0) {
952
+ topology.landmarks!.splice(landmarkIndex, 1);
953
+ this.storage.setItem('topology', JSON.stringify(topology));
954
+ if (this.trackingEngine) {
955
+ this.trackingEngine.updateTopology(topology);
956
+ }
957
+ response.send(JSON.stringify({ success: true }), {
958
+ headers: { 'Content-Type': 'application/json' },
959
+ });
960
+ } else {
961
+ response.send(JSON.stringify({ error: 'Landmark not found' }), {
962
+ code: 404,
963
+ headers: { 'Content-Type': 'application/json' },
964
+ });
965
+ }
966
+ }
967
+ }
968
+
969
+ private handleLandmarkSuggestionsRequest(request: HttpRequest, response: HttpResponse): void {
970
+ if (!this.trackingEngine) {
971
+ response.send(JSON.stringify({ suggestions: [] }), {
972
+ headers: { 'Content-Type': 'application/json' },
973
+ });
974
+ return;
975
+ }
976
+
977
+ const suggestions = this.trackingEngine.getPendingLandmarkSuggestions();
978
+ response.send(JSON.stringify({
979
+ suggestions,
980
+ count: suggestions.length,
981
+ }), {
982
+ headers: { 'Content-Type': 'application/json' },
983
+ });
984
+ }
985
+
986
+ private handleSuggestionActionRequest(
987
+ suggestionId: string,
988
+ action: string,
989
+ response: HttpResponse
990
+ ): void {
991
+ if (!this.trackingEngine) {
992
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
993
+ code: 500,
994
+ headers: { 'Content-Type': 'application/json' },
995
+ });
996
+ return;
997
+ }
998
+
999
+ if (action === 'accept') {
1000
+ const landmark = this.trackingEngine.acceptLandmarkSuggestion(suggestionId);
1001
+ if (landmark) {
1002
+ response.send(JSON.stringify({ success: true, landmark }), {
1003
+ headers: { 'Content-Type': 'application/json' },
1004
+ });
1005
+ } else {
1006
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
1007
+ code: 404,
1008
+ headers: { 'Content-Type': 'application/json' },
1009
+ });
1010
+ }
1011
+ } else if (action === 'reject') {
1012
+ const success = this.trackingEngine.rejectLandmarkSuggestion(suggestionId);
1013
+ if (success) {
1014
+ response.send(JSON.stringify({ success: true }), {
1015
+ headers: { 'Content-Type': 'application/json' },
1016
+ });
1017
+ } else {
1018
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
1019
+ code: 404,
1020
+ headers: { 'Content-Type': 'application/json' },
1021
+ });
1022
+ }
1023
+ } else {
1024
+ response.send(JSON.stringify({ error: 'Invalid action' }), {
1025
+ code: 400,
1026
+ headers: { 'Content-Type': 'application/json' },
1027
+ });
1028
+ }
1029
+ }
1030
+
1031
+ private handleLandmarkTemplatesRequest(response: HttpResponse): void {
1032
+ response.send(JSON.stringify({
1033
+ templates: LANDMARK_TEMPLATES,
1034
+ }), {
1035
+ headers: { 'Content-Type': 'application/json' },
1036
+ });
1037
+ }
1038
+
1039
+ private handleInferRelationshipsRequest(response: HttpResponse): void {
1040
+ const topology = this.getTopology();
1041
+ if (!topology) {
1042
+ response.send(JSON.stringify({ relationships: [] }), {
1043
+ headers: { 'Content-Type': 'application/json' },
1044
+ });
1045
+ return;
1046
+ }
1047
+
1048
+ const inferred = inferRelationships(topology);
1049
+ response.send(JSON.stringify({
1050
+ relationships: inferred,
1051
+ count: inferred.length,
1052
+ }), {
1053
+ headers: { 'Content-Type': 'application/json' },
1054
+ });
1055
+ }
1056
+
765
1057
  private serveEditorUI(response: HttpResponse): void {
766
1058
  response.send(EDITOR_HTML, {
767
1059
  headers: { 'Content-Type': 'text/html' },
@@ -78,6 +78,13 @@ export interface AlertDetails {
78
78
  objectLabel?: string;
79
79
  /** Thumbnail image URL or data */
80
80
  thumbnailUrl?: string;
81
+ // --- Spatial Context from RAG ---
82
+ /** Description of the path taken (from spatial reasoning) */
83
+ pathDescription?: string;
84
+ /** Names of landmarks involved in the movement */
85
+ involvedLandmarks?: string[];
86
+ /** Whether LLM was used for description */
87
+ usedLlm?: boolean;
81
88
  }
82
89
 
83
90
  /** A condition for alert rules */
@@ -211,9 +218,22 @@ export function generateAlertMessage(
211
218
  case 'property_exit':
212
219
  return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
213
220
  case 'movement':
221
+ // If we have a rich description from LLM/RAG, use it
222
+ if (details.objectLabel && details.usedLlm) {
223
+ const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
224
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
225
+ // Include path/landmark context if available
226
+ const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
227
+ return `${details.objectLabel}${pathContext}${transitStr}`;
228
+ }
229
+ // Fallback to basic message with landmark info
214
230
  const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
215
231
  const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
216
- return `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}${transitStr}`;
232
+ let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
233
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
234
+ movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
235
+ }
236
+ return `${movementDesc}${transitStr}`;
217
237
  case 'unusual_path':
218
238
  return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
219
239
  case 'dwell_time':