@blueharford/scrypted-spatial-awareness 0.1.16 → 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.
@@ -14,7 +14,7 @@ import sdk, {
14
14
  Camera,
15
15
  MediaObject,
16
16
  } from '@scrypted/sdk';
17
- import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
17
+ import { CameraTopology, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
18
18
  import {
19
19
  TrackedObject,
20
20
  ObjectSighting,
@@ -25,6 +25,11 @@ import {
25
25
  import { TrackingState } from '../state/tracking-state';
26
26
  import { AlertManager } from '../alerts/alert-manager';
27
27
  import { ObjectCorrelator } from './object-correlator';
28
+ import {
29
+ SpatialReasoningEngine,
30
+ SpatialReasoningConfig,
31
+ SpatialReasoningResult,
32
+ } from './spatial-reasoning';
28
33
 
29
34
  const { systemManager } = sdk;
30
35
 
@@ -43,6 +48,10 @@ export interface TrackingEngineConfig {
43
48
  objectAlertCooldown: number;
44
49
  /** Use LLM for enhanced descriptions */
45
50
  useLlmDescriptions: boolean;
51
+ /** Enable landmark learning from AI */
52
+ enableLandmarkLearning?: boolean;
53
+ /** Minimum confidence for landmark suggestions */
54
+ landmarkConfidenceThreshold?: number;
46
55
  }
47
56
 
48
57
  export class TrackingEngine {
@@ -52,13 +61,14 @@ export class TrackingEngine {
52
61
  private config: TrackingEngineConfig;
53
62
  private console: Console;
54
63
  private correlator: ObjectCorrelator;
64
+ private spatialReasoning: SpatialReasoningEngine;
55
65
  private listeners: Map<string, EventListenerRegister> = new Map();
56
66
  private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
57
67
  private lostCheckInterval: NodeJS.Timeout | null = null;
58
68
  /** Track last alert time per object to enforce cooldown */
59
69
  private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
60
- /** Cache for LLM device reference */
61
- private llmDevice: ObjectDetection | null = null;
70
+ /** Callback for topology changes (e.g., landmark suggestions) */
71
+ private onTopologyChange?: (topology: CameraTopology) => void;
62
72
 
63
73
  constructor(
64
74
  topology: CameraTopology,
@@ -73,6 +83,21 @@ export class TrackingEngine {
73
83
  this.config = config;
74
84
  this.console = console;
75
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;
76
101
  }
77
102
 
78
103
  /** Start listening to all cameras in topology */
@@ -207,58 +232,69 @@ export class TrackingEngine {
207
232
  this.objectLastAlertTime.set(globalId, Date.now());
208
233
  }
209
234
 
210
- /** Try to get LLM-enhanced description for movement */
211
- private async getLlmDescription(
235
+ /** Get spatial reasoning result for movement (uses RAG + LLM) */
236
+ private async getSpatialDescription(
212
237
  tracked: TrackedObject,
213
- fromCamera: string,
214
- toCamera: string,
215
- cameraId: string
216
- ): Promise<string | null> {
217
- if (!this.config.useLlmDescriptions) return null;
218
-
238
+ fromCameraId: string,
239
+ toCameraId: string,
240
+ transitTime: number,
241
+ currentCameraId: string
242
+ ): Promise<SpatialReasoningResult | null> {
219
243
  try {
220
- // Find LLM plugin device if not cached
221
- if (!this.llmDevice) {
222
- for (const id of Object.keys(systemManager.getSystemState())) {
223
- const device = systemManager.getDeviceById(id);
224
- if (device?.interfaces?.includes(ScryptedInterface.ObjectDetection) &&
225
- device.name?.toLowerCase().includes('llm')) {
226
- this.llmDevice = device as unknown as ObjectDetection;
227
- this.console.log(`Found LLM device: ${device.name}`);
228
- break;
229
- }
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();
230
250
  }
231
251
  }
232
252
 
233
- if (!this.llmDevice) return null;
234
-
235
- // Get snapshot from camera for LLM analysis
236
- const camera = systemManager.getDeviceById<Camera>(cameraId);
237
- if (!camera?.interfaces?.includes(ScryptedInterface.Camera)) return null;
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
+ );
238
261
 
239
- const picture = await camera.takePicture();
240
- if (!picture) return null;
262
+ // Optionally trigger landmark learning
263
+ if (this.config.enableLandmarkLearning && mediaObject) {
264
+ this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
265
+ }
241
266
 
242
- // Ask LLM to describe the movement
243
- const prompt = `Describe this ${tracked.className} in one short sentence. ` +
244
- `They are moving from the ${fromCamera} area towards the ${toCamera}. ` +
245
- `Include details like: gender (man/woman), clothing color, vehicle color/type if applicable. ` +
246
- `Example: "Man in blue jacket walking from garage towards front door" or ` +
247
- `"Black SUV driving from driveway towards street"`;
267
+ return result;
268
+ } catch (e) {
269
+ this.console.warn('Spatial reasoning failed:', e);
270
+ return null;
271
+ }
272
+ }
248
273
 
249
- const result = await this.llmDevice.detectObjects(picture, {
250
- settings: { prompt }
251
- } as any);
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
+ );
252
289
 
253
- // Extract description from LLM response
254
- if (result.detections?.[0]?.label) {
255
- return result.detections[0].label;
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
+ );
256
295
  }
257
-
258
- return null;
259
296
  } catch (e) {
260
- this.console.warn('LLM description failed:', e);
261
- return null;
297
+ // Landmark learning is best-effort, don't log errors
262
298
  }
263
299
  }
264
300
 
@@ -300,11 +336,12 @@ export class TrackingEngine {
300
336
 
301
337
  // Check loitering threshold and per-object cooldown before alerting
302
338
  if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
303
- // Try to get LLM-enhanced description
304
- const llmDescription = await this.getLlmDescription(
339
+ // Get spatial reasoning result with RAG context
340
+ const spatialResult = await this.getSpatialDescription(
305
341
  tracked,
306
- lastSighting.cameraName,
307
- sighting.cameraName,
342
+ lastSighting.cameraId,
343
+ sighting.cameraId,
344
+ transitDuration,
308
345
  sighting.cameraId
309
346
  );
310
347
 
@@ -316,8 +353,12 @@ export class TrackingEngine {
316
353
  toCameraName: sighting.cameraName,
317
354
  transitTime: transitDuration,
318
355
  objectClass: sighting.detection.className,
319
- objectLabel: llmDescription || sighting.detection.label,
356
+ objectLabel: spatialResult?.description || sighting.detection.label,
320
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,
321
362
  });
322
363
 
323
364
  this.recordAlertTime(tracked.globalId);
@@ -354,10 +395,12 @@ export class TrackingEngine {
354
395
  // Generate entry alert if this is an entry point
355
396
  // Entry alerts also respect loitering threshold and cooldown
356
397
  if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
357
- const llmDescription = await this.getLlmDescription(
398
+ // Get spatial reasoning for entry event
399
+ const spatialResult = await this.getSpatialDescription(
358
400
  tracked,
359
- 'outside',
360
- sighting.cameraName,
401
+ 'outside', // Virtual "outside" location for entry
402
+ sighting.cameraId,
403
+ 0,
361
404
  sighting.cameraId
362
405
  );
363
406
 
@@ -365,8 +408,10 @@ export class TrackingEngine {
365
408
  cameraId: sighting.cameraId,
366
409
  cameraName: sighting.cameraName,
367
410
  objectClass: sighting.detection.className,
368
- objectLabel: llmDescription || sighting.detection.label,
411
+ objectLabel: spatialResult?.description || sighting.detection.label,
369
412
  detectionId: sighting.detectionId,
413
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
414
+ usedLlm: spatialResult?.usedLlm,
370
415
  });
371
416
 
372
417
  this.recordAlertTime(globalId);
@@ -471,6 +516,45 @@ export class TrackingEngine {
471
516
  updateTopology(topology: CameraTopology): void {
472
517
  this.topology = topology;
473
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;
474
558
  }
475
559
 
476
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';
@@ -106,7 +113,21 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
106
113
  type: 'boolean',
107
114
  defaultValue: true,
108
115
  description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
109
- group: 'Tracking',
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',
110
131
  },
111
132
 
112
133
  // MQTT Settings
@@ -270,6 +291,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
270
291
  loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
271
292
  objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
272
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,
273
296
  };
274
297
 
275
298
  this.trackingEngine = new TrackingEngine(
@@ -280,6 +303,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
280
303
  this.console
281
304
  );
282
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
+
283
312
  await this.trackingEngine.startTracking();
284
313
  this.console.log('Tracking engine started');
285
314
  }
@@ -546,7 +575,9 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
546
575
  key === 'useVisualMatching' ||
547
576
  key === 'loiteringThreshold' ||
548
577
  key === 'objectAlertCooldown' ||
549
- key === 'useLlmDescriptions'
578
+ key === 'useLlmDescriptions' ||
579
+ key === 'enableLandmarkLearning' ||
580
+ key === 'landmarkConfidenceThreshold'
550
581
  ) {
551
582
  const topologyJson = this.storage.getItem('topology');
552
583
  if (topologyJson) {
@@ -609,6 +640,34 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
609
640
  return this.handleFloorPlanRequest(request, response);
610
641
  }
611
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
+
612
671
  // UI Routes
613
672
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
614
673
  return this.serveEditorUI(response);
@@ -791,6 +850,210 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
791
850
  }
792
851
  }
793
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
+
794
1057
  private serveEditorUI(response: HttpResponse): void {
795
1058
  response.send(EDITOR_HTML, {
796
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':