@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/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.6",
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",
@@ -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}🚶 Entry Detected`;
169
+ return `${prefix}${objectType} Arrived`;
165
170
  case 'property_exit':
166
- return `${prefix}🚶 Exit Detected`;
171
+ return `${prefix}${objectType} Left`;
167
172
  case 'movement':
168
- return `${prefix}🚶 Movement Detected`;
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 Path`;
177
+ return `${prefix}Unusual Route`;
171
178
  case 'dwell_time':
172
- return `${prefix}⏱️ Extended Presence`;
179
+ return `${prefix}${objectType} Lingering`;
173
180
  case 'restricted_zone':
174
- return `${prefix}Restricted Zone Alert`;
181
+ return `${prefix}Restricted Zone!`;
175
182
  case 'lost_tracking':
176
- return `${prefix}Lost Tracking`;
183
+ return `${prefix}${objectType} Lost`;
177
184
  case 'reappearance':
178
- return `${prefix}Object Reappeared`;
185
+ return `${prefix}${objectType} Reappeared`;
179
186
  default:
180
- return `${prefix}Spatial Awareness Alert`;
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) 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);
@@ -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 - low score but not zero
137
- // (allows for uncharted paths)
138
- return 0.2;
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
- return 0.3; // No connection defined
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: 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