@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/README.md +62 -0
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +554 -137
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/object-correlator.ts +32 -7
- package/src/core/spatial-reasoning.ts +315 -44
- package/src/core/tracking-engine.ts +57 -19
- package/src/models/alert.ts +41 -14
package/out/plugin.zip
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -46,7 +46,24 @@ export class ObjectCorrelator {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
if (candidates.length === 0)
|
|
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
|
|
141
|
-
const MAX_UNCHARTED_TRANSIT =
|
|
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
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
307
|
-
private
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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.
|
|
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
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
//
|
|
437
|
-
const
|
|
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
|
-
|
|
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.
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
850
|
+
const content = result?.choices?.[0]?.message?.content;
|
|
851
|
+
if (content && typeof content === 'string') {
|
|
581
852
|
try {
|
|
582
|
-
const parsed = JSON.parse(
|
|
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
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
551
|
+
objectLabel: spatialResult.description,
|
|
536
552
|
detectionId: sighting.detectionId,
|
|
537
|
-
involvedLandmarks: spatialResult
|
|
538
|
-
usedLlm: spatialResult
|
|
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
|
|
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:
|
|
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);
|