@blueharford/scrypted-spatial-awareness 0.4.6 → 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 +141 -355
- 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/alerts/alert-manager.ts +16 -9
- package/src/core/object-correlator.ts +41 -7
- package/src/core/spatial-reasoning.ts +315 -44
- package/src/core/tracking-engine.ts +57 -19
- package/src/main.ts +3 -3
- package/src/models/alert.ts +41 -14
package/out/plugin.zip
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -159,25 +159,32 @@ export class AlertManager {
|
|
|
159
159
|
const prefix = alert.severity === 'critical' ? '🚨 ' :
|
|
160
160
|
alert.severity === 'warning' ? '⚠️ ' : '';
|
|
161
161
|
|
|
162
|
+
// Use object class in title
|
|
163
|
+
const objectType = alert.details.objectClass
|
|
164
|
+
? alert.details.objectClass.charAt(0).toUpperCase() + alert.details.objectClass.slice(1)
|
|
165
|
+
: 'Object';
|
|
166
|
+
|
|
162
167
|
switch (alert.type) {
|
|
163
168
|
case 'property_entry':
|
|
164
|
-
return `${prefix}
|
|
169
|
+
return `${prefix}${objectType} Arrived`;
|
|
165
170
|
case 'property_exit':
|
|
166
|
-
return `${prefix}
|
|
171
|
+
return `${prefix}${objectType} Left`;
|
|
167
172
|
case 'movement':
|
|
168
|
-
|
|
173
|
+
// Include destination in title
|
|
174
|
+
const dest = alert.details.toCameraName || 'area';
|
|
175
|
+
return `${prefix}${objectType} → ${dest}`;
|
|
169
176
|
case 'unusual_path':
|
|
170
|
-
return `${prefix}Unusual
|
|
177
|
+
return `${prefix}Unusual Route`;
|
|
171
178
|
case 'dwell_time':
|
|
172
|
-
return `${prefix}
|
|
179
|
+
return `${prefix}${objectType} Lingering`;
|
|
173
180
|
case 'restricted_zone':
|
|
174
|
-
return `${prefix}Restricted Zone
|
|
181
|
+
return `${prefix}Restricted Zone!`;
|
|
175
182
|
case 'lost_tracking':
|
|
176
|
-
return `${prefix}Lost
|
|
183
|
+
return `${prefix}${objectType} Lost`;
|
|
177
184
|
case 'reappearance':
|
|
178
|
-
return `${prefix}
|
|
185
|
+
return `${prefix}${objectType} Reappeared`;
|
|
179
186
|
default:
|
|
180
|
-
return `${prefix}Spatial
|
|
187
|
+
return `${prefix}Spatial Alert`;
|
|
181
188
|
}
|
|
182
189
|
}
|
|
183
190
|
|
|
@@ -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);
|
|
@@ -125,6 +142,9 @@ export class ObjectCorrelator {
|
|
|
125
142
|
return 1.0;
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
// Calculate transit time first
|
|
146
|
+
const transitTime = sighting.timestamp - lastSighting.timestamp;
|
|
147
|
+
|
|
128
148
|
// Find connection between cameras
|
|
129
149
|
const connection = findConnection(
|
|
130
150
|
this.topology,
|
|
@@ -133,12 +153,25 @@ export class ObjectCorrelator {
|
|
|
133
153
|
);
|
|
134
154
|
|
|
135
155
|
if (!connection) {
|
|
136
|
-
// No defined connection -
|
|
137
|
-
// (
|
|
138
|
-
|
|
156
|
+
// No defined connection - still allow correlation based on reasonable timing
|
|
157
|
+
// Allow up to 5 minutes transit between any cameras (property could be large)
|
|
158
|
+
const MAX_UNCHARTED_TRANSIT = 300000; // 5 minutes
|
|
159
|
+
if (transitTime > 0 && transitTime < MAX_UNCHARTED_TRANSIT) {
|
|
160
|
+
// Score based on how reasonable the timing is
|
|
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
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return 0.3; // Even long transits get some credit
|
|
139
174
|
}
|
|
140
|
-
|
|
141
|
-
const transitTime = sighting.timestamp - lastSighting.timestamp;
|
|
142
175
|
const { min, typical, max } = connection.transitTime;
|
|
143
176
|
|
|
144
177
|
// Way outside range
|
|
@@ -217,7 +250,8 @@ export class ObjectCorrelator {
|
|
|
217
250
|
);
|
|
218
251
|
|
|
219
252
|
if (!connection) {
|
|
220
|
-
|
|
253
|
+
// No connection defined - give neutral score (don't penalize)
|
|
254
|
+
return 0.5;
|
|
221
255
|
}
|
|
222
256
|
|
|
223
257
|
let score = 0;
|
|
@@ -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
|
|