@blueharford/scrypted-spatial-awareness 0.4.7 → 0.4.8-beta.1

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.
package/out/plugin.zip ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.4.7",
3
+ "version": "0.4.8-beta.1",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -46,7 +46,24 @@ export class ObjectCorrelator {
46
46
  }
47
47
  }
48
48
 
49
- if (candidates.length === 0) return null;
49
+ if (candidates.length === 0) {
50
+ // No candidates above threshold - try to find best match with relaxed criteria
51
+ // This helps when there's only one object of this class active
52
+ const sameClassObjects = activeObjects.filter(
53
+ o => o.className === sighting.detection.className
54
+ );
55
+
56
+ if (sameClassObjects.length === 1) {
57
+ // Only one object of this class - likely the same one
58
+ const candidate = await this.evaluateCandidate(sameClassObjects[0], sighting);
59
+ // Accept with lower threshold if timing is reasonable
60
+ if (candidate.confidence >= 0.3 && candidate.factors.timing > 0) {
61
+ return candidate;
62
+ }
63
+ }
64
+
65
+ return null;
66
+ }
50
67
 
51
68
  // Sort by confidence (highest first)
52
69
  candidates.sort((a, b) => b.confidence - a.confidence);
@@ -137,15 +154,23 @@ export class ObjectCorrelator {
137
154
 
138
155
  if (!connection) {
139
156
  // No defined connection - still allow correlation based on reasonable timing
140
- // Allow up to 2 minutes transit between any cameras
141
- const MAX_UNCHARTED_TRANSIT = 120000; // 2 minutes
157
+ // Allow up to 5 minutes transit between any cameras (property could be large)
158
+ const MAX_UNCHARTED_TRANSIT = 300000; // 5 minutes
142
159
  if (transitTime > 0 && transitTime < MAX_UNCHARTED_TRANSIT) {
143
160
  // Score based on how reasonable the timing is
144
- // Shorter transits are more likely to be the same object
145
- const timingScore = Math.max(0.3, 1 - (transitTime / MAX_UNCHARTED_TRANSIT));
146
- return timingScore;
161
+ // Give higher base score for reasonable transits (encourages matching)
162
+ if (transitTime < 60000) {
163
+ // Under 1 minute - very likely same object
164
+ return 0.9;
165
+ } else if (transitTime < 120000) {
166
+ // Under 2 minutes - probably same object
167
+ return 0.7;
168
+ } else {
169
+ // 2-5 minutes - possible but less certain
170
+ return Math.max(0.4, 0.7 - (transitTime - 120000) / 180000 * 0.3);
171
+ }
147
172
  }
148
- return 0.2;
173
+ return 0.3; // Even long transits get some credit
149
174
  }
150
175
  const { min, typical, max } = connection.transitTime;
151
176
 
@@ -9,6 +9,7 @@ import sdk, {
9
9
  ObjectDetection,
10
10
  Camera,
11
11
  MediaObject,
12
+ ScryptedDevice,
12
13
  } from '@scrypted/sdk';
13
14
  import {
14
15
  CameraTopology,
@@ -61,11 +62,17 @@ interface ContextChunk {
61
62
  metadata: Record<string, any>;
62
63
  }
63
64
 
65
+ /** Interface for ChatCompletion devices (from @scrypted/llm plugin) */
66
+ interface ChatCompletionDevice extends ScryptedDevice {
67
+ getChatCompletion?(params: any): Promise<any>;
68
+ streamChatCompletion?(params: any): AsyncGenerator<any>;
69
+ }
70
+
64
71
  export class SpatialReasoningEngine {
65
72
  private config: SpatialReasoningConfig;
66
73
  private console: Console;
67
74
  private topology: CameraTopology | null = null;
68
- private llmDevice: ObjectDetection | null = null;
75
+ private llmDevice: ChatCompletionDevice | null = null;
69
76
  private contextChunks: ContextChunk[] = [];
70
77
  private topologyContextCache: string | null = null;
71
78
  private contextCacheTime: number = 0;
@@ -303,30 +310,213 @@ export class SpatialReasoningEngine {
303
310
  return relevant;
304
311
  }
305
312
 
306
- /** Find or initialize LLM device */
307
- private async findLlmDevice(): Promise<ObjectDetection | null> {
313
+ private llmSearched: boolean = false;
314
+ private llmProvider: string | null = null;
315
+
316
+ /** Find or initialize LLM device - looks for ChatCompletion interface from @scrypted/llm plugin */
317
+ private async findLlmDevice(): Promise<ChatCompletionDevice | null> {
308
318
  if (this.llmDevice) return this.llmDevice;
319
+ if (this.llmSearched) return null; // Already searched and found nothing
320
+
321
+ this.llmSearched = true;
309
322
 
310
323
  try {
324
+ // Look for devices with ChatCompletion interface (the correct interface for @scrypted/llm)
311
325
  for (const id of Object.keys(systemManager.getSystemState())) {
312
326
  const device = systemManager.getDeviceById(id);
313
- if (device?.interfaces?.includes(ScryptedInterface.ObjectDetection)) {
314
- const name = device.name?.toLowerCase() || '';
315
- if (name.includes('llm') || name.includes('gpt') || name.includes('claude') ||
316
- name.includes('ollama') || name.includes('gemini')) {
317
- this.llmDevice = device as unknown as ObjectDetection;
318
- this.console.log(`Found LLM device: ${device.name}`);
319
- return this.llmDevice;
327
+ if (!device) continue;
328
+
329
+ // Check if this device has ChatCompletion interface
330
+ // The @scrypted/llm plugin exposes ChatCompletion, not ObjectDetection
331
+ if (device.interfaces?.includes('ChatCompletion')) {
332
+ const deviceName = device.name?.toLowerCase() || '';
333
+ const pluginId = (device as any).pluginId?.toLowerCase() || '';
334
+
335
+ // Identify the provider type for logging
336
+ let providerType = 'Unknown';
337
+ if (pluginId.includes('@scrypted/llm') || pluginId.includes('llm')) {
338
+ providerType = 'Scrypted LLM';
320
339
  }
340
+ if (deviceName.includes('openai') || deviceName.includes('gpt')) {
341
+ providerType = 'OpenAI';
342
+ } else if (deviceName.includes('anthropic') || deviceName.includes('claude')) {
343
+ providerType = 'Anthropic';
344
+ } else if (deviceName.includes('ollama')) {
345
+ providerType = 'Ollama';
346
+ } else if (deviceName.includes('gemini') || deviceName.includes('google')) {
347
+ providerType = 'Google';
348
+ } else if (deviceName.includes('llama')) {
349
+ providerType = 'llama.cpp';
350
+ }
351
+
352
+ this.llmDevice = device as unknown as ChatCompletionDevice;
353
+ this.llmProvider = `${providerType} (${device.name})`;
354
+ this.console.log(`[LLM] Connected to ${providerType}: ${device.name}`);
355
+ this.console.log(`[LLM] Plugin: ${pluginId || 'N/A'}`);
356
+ this.console.log(`[LLM] Interfaces: ${device.interfaces?.join(', ')}`);
357
+ return this.llmDevice;
321
358
  }
322
359
  }
360
+
361
+ // If we get here, no LLM plugin found
362
+ this.console.warn('[LLM] No ChatCompletion device found. Install @scrypted/llm for enhanced descriptions.');
363
+ this.console.warn('[LLM] Falling back to rule-based descriptions using topology data.');
364
+
323
365
  } catch (e) {
324
- this.console.warn('Error finding LLM device:', e);
366
+ this.console.error('[LLM] Error searching for LLM device:', e);
325
367
  }
326
368
 
327
369
  return null;
328
370
  }
329
371
 
372
+ /** Get the current LLM provider name */
373
+ getLlmProvider(): string | null {
374
+ return this.llmProvider;
375
+ }
376
+
377
+ /** Check if LLM is available */
378
+ isLlmAvailable(): boolean {
379
+ return this.llmDevice !== null;
380
+ }
381
+
382
+ /** Generate entry description when object enters property */
383
+ generateEntryDescription(
384
+ tracked: TrackedObject,
385
+ cameraId: string
386
+ ): SpatialReasoningResult {
387
+ if (!this.topology) {
388
+ return {
389
+ description: `${this.capitalizeFirst(tracked.className)} entered property`,
390
+ involvedLandmarks: [],
391
+ confidence: 0.5,
392
+ usedLlm: false,
393
+ };
394
+ }
395
+
396
+ const camera = findCamera(this.topology, cameraId);
397
+ if (!camera) {
398
+ return {
399
+ description: `${this.capitalizeFirst(tracked.className)} entered property`,
400
+ involvedLandmarks: [],
401
+ confidence: 0.5,
402
+ usedLlm: false,
403
+ };
404
+ }
405
+
406
+ const landmarks = getLandmarksVisibleFromCamera(this.topology, cameraId);
407
+ const objectType = this.capitalizeFirst(tracked.className);
408
+
409
+ // Build entry description using topology context
410
+ const location = this.describeLocation(camera, landmarks, 'to');
411
+
412
+ // Check if we can determine where they came from (e.g., street, neighbor)
413
+ const entryLandmark = landmarks.find(l => l.isEntryPoint);
414
+ const streetLandmark = landmarks.find(l => l.type === 'street');
415
+ const neighborLandmark = landmarks.find(l => l.type === 'neighbor');
416
+
417
+ let source = '';
418
+ if (streetLandmark) {
419
+ source = ` from ${streetLandmark.name}`;
420
+ } else if (neighborLandmark) {
421
+ source = ` from ${neighborLandmark.name}`;
422
+ }
423
+
424
+ return {
425
+ description: `${objectType} arrived at ${location}${source}`,
426
+ involvedLandmarks: landmarks,
427
+ confidence: 0.8,
428
+ usedLlm: false,
429
+ };
430
+ }
431
+
432
+ /** Generate exit description when object leaves property */
433
+ generateExitDescription(
434
+ tracked: TrackedObject,
435
+ cameraId: string
436
+ ): SpatialReasoningResult {
437
+ if (!this.topology) {
438
+ return {
439
+ description: `${this.capitalizeFirst(tracked.className)} left property`,
440
+ involvedLandmarks: [],
441
+ confidence: 0.5,
442
+ usedLlm: false,
443
+ };
444
+ }
445
+
446
+ const camera = findCamera(this.topology, cameraId);
447
+ if (!camera) {
448
+ return {
449
+ description: `${this.capitalizeFirst(tracked.className)} left property`,
450
+ involvedLandmarks: [],
451
+ confidence: 0.5,
452
+ usedLlm: false,
453
+ };
454
+ }
455
+
456
+ const landmarks = getLandmarksVisibleFromCamera(this.topology, cameraId);
457
+ const objectType = this.capitalizeFirst(tracked.className);
458
+
459
+ // Build exit description
460
+ const location = this.describeLocation(camera, landmarks, 'from');
461
+
462
+ // Check for exit point landmarks
463
+ const exitLandmark = landmarks.find(l => l.isExitPoint);
464
+ const streetLandmark = landmarks.find(l => l.type === 'street');
465
+
466
+ let destination = '';
467
+ if (streetLandmark) {
468
+ destination = ` towards ${streetLandmark.name}`;
469
+ } else if (exitLandmark) {
470
+ destination = ` via ${exitLandmark.name}`;
471
+ }
472
+
473
+ // Include time on property if available
474
+ const dwellTime = Math.round((tracked.lastSeen - tracked.firstSeen) / 1000);
475
+ let timeContext = '';
476
+ if (dwellTime > 60) {
477
+ timeContext = ` after ${Math.round(dwellTime / 60)}m on property`;
478
+ } else if (dwellTime > 10) {
479
+ timeContext = ` after ${dwellTime}s`;
480
+ }
481
+
482
+ // Summarize journey if they visited multiple cameras (use landmarks from topology)
483
+ let journeyContext = '';
484
+ if (tracked.journey.length > 0 && this.topology) {
485
+ const visitedLandmarks: string[] = [];
486
+
487
+ // Get landmarks from entry camera
488
+ if (tracked.entryCamera) {
489
+ const entryLandmarks = getLandmarksVisibleFromCamera(this.topology, tracked.entryCamera);
490
+ const entryLandmark = entryLandmarks.find(l => l.isEntryPoint || l.type === 'access') || entryLandmarks[0];
491
+ if (entryLandmark) {
492
+ visitedLandmarks.push(entryLandmark.name);
493
+ }
494
+ }
495
+
496
+ // Get landmarks from journey segments
497
+ for (const segment of tracked.journey) {
498
+ const segmentLandmarks = getLandmarksVisibleFromCamera(this.topology, segment.toCameraId);
499
+ const segmentLandmark = segmentLandmarks.find(l =>
500
+ !visitedLandmarks.includes(l.name) && (l.type === 'access' || l.type === 'zone' || l.type === 'structure')
501
+ );
502
+ if (segmentLandmark && !visitedLandmarks.includes(segmentLandmark.name)) {
503
+ visitedLandmarks.push(segmentLandmark.name);
504
+ }
505
+ }
506
+
507
+ if (visitedLandmarks.length > 1) {
508
+ journeyContext = ` — visited ${visitedLandmarks.join(' → ')}`;
509
+ }
510
+ }
511
+
512
+ return {
513
+ description: `${objectType} left ${location}${destination}${timeContext}${journeyContext}`,
514
+ involvedLandmarks: landmarks,
515
+ confidence: 0.8,
516
+ usedLlm: false,
517
+ };
518
+ }
519
+
330
520
  /** Generate rich movement description using LLM */
331
521
  async generateMovementDescription(
332
522
  tracked: TrackedObject,
@@ -415,28 +605,92 @@ export class SpatialReasoningEngine {
415
605
  const objectType = this.capitalizeFirst(tracked.className);
416
606
  const transitSecs = Math.round(transitTime / 1000);
417
607
 
418
- // Build origin description
419
- let origin = fromCamera.name;
420
- if (fromLandmarks.length > 0) {
421
- const nearLandmark = fromLandmarks[0];
422
- origin = `near ${nearLandmark.name}`;
423
- } else if (fromCamera.context?.coverageDescription) {
424
- origin = fromCamera.context.coverageDescription.split('.')[0];
425
- }
608
+ // Get connection for path context
609
+ const connection = this.topology ? findConnection(this.topology, fromCamera.deviceId, toCamera.deviceId) : null;
610
+
611
+ // Build origin description using landmarks, camera context, or camera name
612
+ let origin = this.describeLocation(fromCamera, fromLandmarks, 'from');
426
613
 
427
614
  // Build destination description
428
- let destination = toCamera.name;
429
- if (toLandmarks.length > 0) {
430
- const nearLandmark = toLandmarks[0];
431
- destination = `towards ${nearLandmark.name}`;
432
- } else if (toCamera.context?.coverageDescription) {
433
- destination = `towards ${toCamera.context.coverageDescription.split('.')[0]}`;
615
+ let destination = this.describeLocation(toCamera, toLandmarks, 'to');
616
+
617
+ // Check if we have a named path/connection
618
+ let pathContext = '';
619
+ if (connection?.name) {
620
+ pathContext = ` via ${connection.name}`;
621
+ } else if (connection?.pathLandmarks?.length && this.topology) {
622
+ const pathNames = connection.pathLandmarks
623
+ .map(id => findLandmark(this.topology!, id)?.name)
624
+ .filter(Boolean);
625
+ if (pathNames.length > 0) {
626
+ pathContext = ` past ${pathNames.join(' and ')}`;
627
+ }
628
+ }
629
+
630
+ // Include journey context if this is not the first camera
631
+ let journeyContext = '';
632
+ if (tracked.journey.length > 0) {
633
+ const totalTime = Math.round((Date.now() - tracked.firstSeen) / 1000);
634
+ if (totalTime > 60) {
635
+ journeyContext = ` (${Math.round(totalTime / 60)}m on property)`;
636
+ }
637
+ }
638
+
639
+ // Determine movement verb based on transit time and object type
640
+ const verb = this.getMovementVerb(tracked.className, transitSecs);
641
+
642
+ return `${objectType} ${verb} ${origin} heading ${destination}${pathContext}${journeyContext}`;
643
+ }
644
+
645
+ /** Describe a location using landmarks, camera context, or camera name */
646
+ private describeLocation(camera: CameraNode, landmarks: Landmark[], direction: 'from' | 'to'): string {
647
+ // Priority 1: Use entry/exit landmarks
648
+ const entryExitLandmark = landmarks.find(l =>
649
+ (direction === 'from' && l.isExitPoint) || (direction === 'to' && l.isEntryPoint)
650
+ );
651
+ if (entryExitLandmark) {
652
+ return direction === 'from' ? `the ${entryExitLandmark.name}` : `the ${entryExitLandmark.name}`;
653
+ }
654
+
655
+ // Priority 2: Use access landmarks (driveway, walkway, etc.)
656
+ const accessLandmark = landmarks.find(l => l.type === 'access');
657
+ if (accessLandmark) {
658
+ return `the ${accessLandmark.name}`;
434
659
  }
435
660
 
436
- // Build transit string
437
- const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
661
+ // Priority 3: Use zone landmarks (front yard, back yard)
662
+ const zoneLandmark = landmarks.find(l => l.type === 'zone');
663
+ if (zoneLandmark) {
664
+ return `the ${zoneLandmark.name}`;
665
+ }
666
+
667
+ // Priority 4: Use any landmark
668
+ if (landmarks.length > 0) {
669
+ return `near ${landmarks[0].name}`;
670
+ }
438
671
 
439
- return `${objectType} moving from ${origin} ${destination}${transitStr}`;
672
+ // Priority 5: Use camera coverage description
673
+ if (camera.context?.coverageDescription) {
674
+ const desc = camera.context.coverageDescription.split('.')[0].toLowerCase();
675
+ return `the ${desc}`;
676
+ }
677
+
678
+ // Fallback: Generic description (no camera name inference - use topology for context)
679
+ return direction === 'from' ? 'property' : 'property';
680
+ }
681
+
682
+ /** Get appropriate movement verb based on context */
683
+ private getMovementVerb(className: string, transitSecs: number): string {
684
+ if (className === 'car' || className === 'vehicle' || className === 'truck') {
685
+ return transitSecs < 10 ? 'driving from' : 'moved from';
686
+ }
687
+ if (transitSecs < 5) {
688
+ return 'walking from';
689
+ }
690
+ if (transitSecs < 30) {
691
+ return 'moved from';
692
+ }
693
+ return 'traveled from';
440
694
  }
441
695
 
442
696
  /** Build path description from connection */
@@ -458,7 +712,7 @@ export class SpatialReasoningEngine {
458
712
  return connection.name || undefined;
459
713
  }
460
714
 
461
- /** Get LLM-enhanced description */
715
+ /** Get LLM-enhanced description using ChatCompletion interface */
462
716
  private async getLlmEnhancedDescription(
463
717
  tracked: TrackedObject,
464
718
  fromCamera: CameraNode,
@@ -469,7 +723,7 @@ export class SpatialReasoningEngine {
469
723
  mediaObject: MediaObject
470
724
  ): Promise<string | null> {
471
725
  const llm = await this.findLlmDevice();
472
- if (!llm) return null;
726
+ if (!llm || !llm.getChatCompletion) return null;
473
727
 
474
728
  try {
475
729
  // Retrieve relevant context for RAG
@@ -492,14 +746,22 @@ export class SpatialReasoningEngine {
492
746
  ragContext
493
747
  );
494
748
 
495
- // Call LLM
496
- const result = await llm.detectObjects(mediaObject, {
497
- settings: { prompt }
498
- } as any);
749
+ // Call LLM using ChatCompletion interface
750
+ const result = await llm.getChatCompletion({
751
+ messages: [
752
+ {
753
+ role: 'user',
754
+ content: prompt,
755
+ },
756
+ ],
757
+ max_tokens: 150,
758
+ temperature: 0.7,
759
+ });
499
760
 
500
- // Extract description from result
501
- if (result.detections?.[0]?.label) {
502
- return result.detections[0].label;
761
+ // Extract description from ChatCompletion result
762
+ const content = result?.choices?.[0]?.message?.content;
763
+ if (content && typeof content === 'string') {
764
+ return content.trim();
503
765
  }
504
766
 
505
767
  return null;
@@ -547,7 +809,7 @@ Examples of good descriptions:
547
809
  Generate ONLY the description, nothing else:`;
548
810
  }
549
811
 
550
- /** Suggest a new landmark based on AI analysis */
812
+ /** Suggest a new landmark based on AI analysis using ChatCompletion */
551
813
  async suggestLandmark(
552
814
  cameraId: string,
553
815
  mediaObject: MediaObject,
@@ -557,7 +819,7 @@ Generate ONLY the description, nothing else:`;
557
819
  if (!this.config.enableLandmarkLearning) return null;
558
820
 
559
821
  const llm = await this.findLlmDevice();
560
- if (!llm) return null;
822
+ if (!llm || !llm.getChatCompletion) return null;
561
823
 
562
824
  try {
563
825
  const prompt = `Analyze this security camera image. A ${objectClass} was detected.
@@ -573,13 +835,22 @@ If you can identify a clear landmark feature, respond with ONLY a JSON object:
573
835
 
574
836
  If no clear landmark is identifiable, respond with: {"name": null}`;
575
837
 
576
- const result = await llm.detectObjects(mediaObject, {
577
- settings: { prompt }
578
- } as any);
838
+ // Call LLM using ChatCompletion interface
839
+ const result = await llm.getChatCompletion({
840
+ messages: [
841
+ {
842
+ role: 'user',
843
+ content: prompt,
844
+ },
845
+ ],
846
+ max_tokens: 100,
847
+ temperature: 0.3,
848
+ });
579
849
 
580
- if (result.detections?.[0]?.label) {
850
+ const content = result?.choices?.[0]?.message?.content;
851
+ if (content && typeof content === 'string') {
581
852
  try {
582
- const parsed = JSON.parse(result.detections[0].label);
853
+ const parsed = JSON.parse(content.trim());
583
854
  if (parsed.name && parsed.type) {
584
855
  const suggestionId = `suggest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
585
856
 
@@ -516,31 +516,61 @@ export class TrackingEngine {
516
516
  `(ID: ${globalId.slice(0, 8)})`
517
517
  );
518
518
 
519
- // Generate entry alert if this is an entry point
520
- // Entry alerts also respect loitering threshold and cooldown
521
- if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
522
- // Get spatial reasoning for entry event
523
- const spatialResult = await this.getSpatialDescription(
524
- tracked,
525
- 'outside', // Virtual "outside" location for entry
526
- sighting.cameraId,
527
- 0,
528
- sighting.cameraId
529
- );
519
+ // Schedule loitering check - alert after object passes loitering threshold
520
+ // This ensures we don't miss alerts for brief appearances while still filtering noise
521
+ this.scheduleLoiteringAlert(globalId, sighting, isEntryPoint);
522
+ }
523
+ }
524
+
525
+ /** Schedule an alert after loitering threshold passes */
526
+ private scheduleLoiteringAlert(
527
+ globalId: GlobalTrackingId,
528
+ sighting: ObjectSighting,
529
+ isEntryPoint: boolean
530
+ ): void {
531
+ // Check after loitering threshold if object is still being tracked
532
+ setTimeout(async () => {
533
+ const tracked = this.state.getObject(globalId);
534
+ if (!tracked || tracked.state !== 'active') return;
535
+
536
+ // Check if we've already alerted for this object
537
+ if (this.isInAlertCooldown(globalId)) return;
538
+
539
+ // Generate spatial description
540
+ const spatialResult = this.spatialReasoning.generateEntryDescription(
541
+ tracked,
542
+ sighting.cameraId
543
+ );
530
544
 
545
+ if (isEntryPoint) {
546
+ // Entry point - generate property entry alert
531
547
  await this.alertManager.checkAndAlert('property_entry', tracked, {
532
548
  cameraId: sighting.cameraId,
533
549
  cameraName: sighting.cameraName,
534
550
  objectClass: sighting.detection.className,
535
- objectLabel: spatialResult?.description || sighting.detection.label,
551
+ objectLabel: spatialResult.description,
536
552
  detectionId: sighting.detectionId,
537
- involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
538
- usedLlm: spatialResult?.usedLlm,
553
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
554
+ usedLlm: spatialResult.usedLlm,
555
+ });
556
+ } else {
557
+ // Non-entry point - still alert about activity using movement alert type
558
+ // This notifies about any activity around the property using topology context
559
+ await this.alertManager.checkAndAlert('movement', tracked, {
560
+ cameraId: sighting.cameraId,
561
+ cameraName: sighting.cameraName,
562
+ toCameraId: sighting.cameraId,
563
+ toCameraName: sighting.cameraName,
564
+ objectClass: sighting.detection.className,
565
+ objectLabel: spatialResult.description, // Use spatial reasoning description (topology-based)
566
+ detectionId: sighting.detectionId,
567
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
568
+ usedLlm: spatialResult.usedLlm,
539
569
  });
540
-
541
- this.recordAlertTime(globalId);
542
570
  }
543
- }
571
+
572
+ this.recordAlertTime(globalId);
573
+ }, this.config.loiteringThreshold);
544
574
  }
545
575
 
546
576
  /** Attempt to correlate a sighting with existing tracked objects */
@@ -596,15 +626,23 @@ export class TrackingEngine {
596
626
  if (current && current.state === 'pending') {
597
627
  this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
598
628
 
629
+ // Generate rich exit description using topology context
630
+ const spatialResult = this.spatialReasoning.generateExitDescription(
631
+ current,
632
+ sighting.cameraId
633
+ );
634
+
599
635
  this.console.log(
600
- `Object ${tracked.globalId.slice(0, 8)} exited via ${sighting.cameraName}`
636
+ `Object ${tracked.globalId.slice(0, 8)} exited: ${spatialResult.description}`
601
637
  );
602
638
 
603
639
  await this.alertManager.checkAndAlert('property_exit', current, {
604
640
  cameraId: sighting.cameraId,
605
641
  cameraName: sighting.cameraName,
606
642
  objectClass: current.className,
607
- objectLabel: current.label,
643
+ objectLabel: spatialResult.description,
644
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
645
+ usedLlm: spatialResult.usedLlm,
608
646
  });
609
647
  }
610
648
  this.pendingTimers.delete(tracked.globalId);