node-red-contrib-knx-ultimate 7.1.0-beta.2 → 7.1.2
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/CHANGELOG.md +9 -0
- package/README.md +5 -1
- package/nodes/knxUltimateAI.html +1820 -0
- package/nodes/knxUltimateAI.js +16797 -0
- package/nodes/knxUltimateAIHomeAssistant.html +40 -0
- package/nodes/knxUltimateAIHomeAssistant.js +152 -0
- package/nodes/knxUltimateHueController.html +1 -1
- package/nodes/knxUltimateMatterBridge.html +2 -2
- package/nodes/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/de/knxUltimateAI.html +5 -0
- package/nodes/locales/de/knxUltimateAI.json +231 -0
- package/nodes/locales/de/knxUltimateUtility.json +8 -0
- package/nodes/locales/en/knxUltimateAI.html +5 -0
- package/nodes/locales/en/knxUltimateAI.json +234 -0
- package/nodes/locales/en/knxUltimateUtility.json +8 -0
- package/nodes/locales/es/knxUltimateAI.html +5 -0
- package/nodes/locales/es/knxUltimateAI.json +231 -0
- package/nodes/locales/es/knxUltimateUtility.json +9 -1
- package/nodes/locales/fr/knxUltimateAI.html +5 -0
- package/nodes/locales/fr/knxUltimateAI.json +231 -0
- package/nodes/locales/fr/knxUltimateUtility.json +8 -0
- package/nodes/locales/it/knxUltimateAI.html +5 -0
- package/nodes/locales/it/knxUltimateAI.json +234 -0
- package/nodes/locales/it/knxUltimateUtility.json +8 -0
- package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +231 -0
- package/nodes/locales/zh-CN/knxUltimateUtility.json +8 -0
- package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
- package/nodes/plugins/knxUltimate-migration-notice-plugin.html +19 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-html2canvas.esm.js +5 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-index.es.js +5 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +79 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-purify.es.js +3 -0
- package/nodes/plugins/knxUltimateAI-vue/index.html +13 -0
- package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +2 -2
- package/nodes/utils/knxAiCamera.js +302 -0
- package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
- package/nodes/utils/knxAiCerebrum.js +406 -0
- package/nodes/utils/knxAiChatContext.js +496 -0
- package/nodes/utils/knxAiEventHistory.js +521 -0
- package/nodes/utils/knxAiHomeMemory.js +1021 -0
- package/nodes/utils/knxAiScheduler.js +445 -0
- package/nodes/utils/knxAiSemanticContext.js +662 -0
- package/nodes/utils/knxAiTelegramVoice.js +545 -0
- package/nodes/utils/knxAiWebAccess.js +631 -0
- package/package.json +17 -9
- package/resources/KNXAIChatAdapterMappings.js +416 -0
- package/resources/hueControllerMigrationDialog.js +3 -0
- package/resources/knxUtilityMigration.js +3 -0
- package/resources/legacyMigrationNotice.js +143 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
const KNX_AI_CAMERA_REGISTRY_KEY = Symbol.for('node-red.knx-ai.camera-adapters.v1')
|
|
2
|
+
const KNX_AI_CAMERA_IMAGE_MAX_BYTES = 6 * 1024 * 1024
|
|
3
|
+
const KNX_AI_CAMERA_MAX_ACTIONS = 8
|
|
4
|
+
|
|
5
|
+
const clampText = (value, maxChars = 240) => String(value === undefined || value === null ? '' : value)
|
|
6
|
+
.trim()
|
|
7
|
+
.slice(0, Math.max(0, Number(maxChars) || 0))
|
|
8
|
+
|
|
9
|
+
const normalizeSearchText = value => clampText(value, 300)
|
|
10
|
+
.normalize('NFKD')
|
|
11
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
12
|
+
.toLocaleLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
|
|
16
|
+
const uniqueTexts = values => Array.from(new Set((Array.isArray(values) ? values : [])
|
|
17
|
+
.map(value => clampText(value, 240))
|
|
18
|
+
.filter(Boolean)))
|
|
19
|
+
|
|
20
|
+
// Runtime contract for optional camera suites. A package registers one adapter
|
|
21
|
+
// manifest and one provider per configured controller. Providers expose:
|
|
22
|
+
// listCameras({ force }) -> camera[]
|
|
23
|
+
// takeSnapshot({ cameraId, cameraName, highQuality }) -> { data, mediaType, camera }
|
|
24
|
+
// subscribe(listener) -> unsubscribe(), with normalized camera event objects.
|
|
25
|
+
// The global Symbol lets separately installed Node-RED packages share the same
|
|
26
|
+
// in-process registry without either package importing the other one.
|
|
27
|
+
const getKnxAiCameraAdapterRegistry = () => {
|
|
28
|
+
const existing = globalThis[KNX_AI_CAMERA_REGISTRY_KEY]
|
|
29
|
+
if (existing && existing.version === 1 && existing.adapters instanceof Map && existing.providers instanceof Map) return existing
|
|
30
|
+
const registry = {
|
|
31
|
+
version: 1,
|
|
32
|
+
adapters: new Map(),
|
|
33
|
+
providers: new Map(),
|
|
34
|
+
listeners: new Set(),
|
|
35
|
+
registerAdapter (adapter) {
|
|
36
|
+
if (!adapter || !adapter.id) return
|
|
37
|
+
this.adapters.set(String(adapter.id), Object.freeze(Object.assign({}, adapter)))
|
|
38
|
+
this.listeners.forEach(listener => {
|
|
39
|
+
try { listener({ type: 'adapter_registered', adapter: this.adapters.get(String(adapter.id)) }) } catch (error) { /* ignore */ }
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
registerProvider (provider) {
|
|
43
|
+
if (!provider || !provider.id) return
|
|
44
|
+
this.providers.set(String(provider.id), provider)
|
|
45
|
+
this.listeners.forEach(listener => {
|
|
46
|
+
try { listener({ type: 'provider_registered', provider }) } catch (error) { /* ignore */ }
|
|
47
|
+
})
|
|
48
|
+
},
|
|
49
|
+
unregisterProvider (providerId) {
|
|
50
|
+
const id = String(providerId || '')
|
|
51
|
+
const provider = this.providers.get(id)
|
|
52
|
+
if (!provider) return
|
|
53
|
+
this.providers.delete(id)
|
|
54
|
+
this.listeners.forEach(listener => {
|
|
55
|
+
try { listener({ type: 'provider_unregistered', provider }) } catch (error) { /* ignore */ }
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
subscribe (listener) {
|
|
59
|
+
if (typeof listener !== 'function') return () => {}
|
|
60
|
+
this.listeners.add(listener)
|
|
61
|
+
return () => this.listeners.delete(listener)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
globalThis[KNX_AI_CAMERA_REGISTRY_KEY] = registry
|
|
65
|
+
return registry
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const normalizeKnxAiCameraRegistration = (value = {}) => {
|
|
69
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
70
|
+
const id = clampText(source.cameraId || source.id, 160)
|
|
71
|
+
const name = clampText(source.cameraName || source.name || id, 240)
|
|
72
|
+
const state = clampText(source.state, 80).toUpperCase()
|
|
73
|
+
const online = typeof source.online === 'boolean'
|
|
74
|
+
? source.online
|
|
75
|
+
: state
|
|
76
|
+
? ['CONNECTED', 'ONLINE'].includes(state)
|
|
77
|
+
: null
|
|
78
|
+
if (!id && !name) return null
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
name,
|
|
82
|
+
aliases: uniqueTexts([name, id].concat(Array.isArray(source.aliases) ? source.aliases : [])),
|
|
83
|
+
source: clampText(source.source || 'camera-adapter', 80),
|
|
84
|
+
adapterId: clampText(source.adapterId, 120),
|
|
85
|
+
adapterTitle: clampText(source.adapterTitle, 240),
|
|
86
|
+
providerId: clampText(source.providerId, 200),
|
|
87
|
+
controllerId: clampText(source.controllerId, 160),
|
|
88
|
+
controllerName: clampText(source.controllerName, 240),
|
|
89
|
+
nativeCameraId: clampText(source.nativeCameraId, 160),
|
|
90
|
+
state,
|
|
91
|
+
online,
|
|
92
|
+
objectTypes: uniqueTexts(source.objectTypes).map(normalizeCameraObjectType).filter(Boolean).slice(0, 24),
|
|
93
|
+
lines: (Array.isArray(source.lines) ? source.lines : []).slice(0, 80),
|
|
94
|
+
zones: (Array.isArray(source.zones) ? source.zones : []).slice(0, 80),
|
|
95
|
+
lastSeenAt: clampText(source.lastSeenAt || new Date().toISOString(), 64)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resolveKnxAiCamera = ({ target, cameras } = {}) => {
|
|
100
|
+
const requested = normalizeSearchText(target)
|
|
101
|
+
const list = (Array.isArray(cameras) ? cameras : [])
|
|
102
|
+
.map(normalizeKnxAiCameraRegistration)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
if (!requested) return { camera: null, ambiguous: false }
|
|
105
|
+
|
|
106
|
+
const exact = list.filter(camera => [camera.id, camera.name].concat(camera.aliases)
|
|
107
|
+
.some(alias => normalizeSearchText(alias) === requested))
|
|
108
|
+
if (exact.length === 1) return { camera: exact[0], ambiguous: false }
|
|
109
|
+
if (exact.length > 1) return { camera: null, ambiguous: true }
|
|
110
|
+
|
|
111
|
+
const partial = list.filter(camera => [camera.id, camera.name].concat(camera.aliases)
|
|
112
|
+
.some(alias => {
|
|
113
|
+
const normalizedAlias = normalizeSearchText(alias)
|
|
114
|
+
return normalizedAlias && (normalizedAlias.includes(requested) || requested.includes(normalizedAlias))
|
|
115
|
+
}))
|
|
116
|
+
if (partial.length === 1) return { camera: partial[0], ambiguous: false }
|
|
117
|
+
return { camera: null, ambiguous: partial.length > 1 }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const normalizeCameraEventType = value => {
|
|
121
|
+
const raw = normalizeSearchText(value).replace(/\s+/g, '')
|
|
122
|
+
if (['smartdetect', 'objectdetect', 'objectdetection', 'smartdetection'].includes(raw)) return 'smartDetect'
|
|
123
|
+
if (['smartdetectline', 'line', 'linecrossing', 'crossline'].includes(raw)) return 'smartDetectLine'
|
|
124
|
+
if (['smartdetectzone', 'zone', 'intrusionzone', 'intrusion'].includes(raw)) return 'smartDetectZone'
|
|
125
|
+
if (['smartdetectloiterzone', 'loiter', 'loiterzone'].includes(raw)) return 'smartDetectLoiterZone'
|
|
126
|
+
if (['motion', 'movement'].includes(raw)) return 'motion'
|
|
127
|
+
if (['ring', 'doorbell'].includes(raw)) return 'ring'
|
|
128
|
+
if (['smartaudiodetect', 'audio'].includes(raw)) return 'smartAudioDetect'
|
|
129
|
+
return clampText(value, 80)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const normalizeCameraObjectType = value => {
|
|
133
|
+
const raw = normalizeSearchText(value).replace(/\s+/g, '')
|
|
134
|
+
if (['person', 'people', 'persona', 'persone', 'human', 'someone', 'qualcuno'].includes(raw)) return 'person'
|
|
135
|
+
if (['animal', 'animale', 'animali', 'pet', 'pets', 'dog', 'cat', 'cane', 'gatto'].includes(raw)) return 'animal'
|
|
136
|
+
if (['vehicle', 'vehicles', 'veicolo', 'veicoli', 'car', 'auto', 'automobile'].includes(raw)) return 'vehicle'
|
|
137
|
+
if (['face', 'volto', 'faccia'].includes(raw)) return 'face'
|
|
138
|
+
if (['licenseplate', 'numberplate', 'targa'].includes(raw)) return 'licenseplate'
|
|
139
|
+
if (['package', 'parcel', 'pacco'].includes(raw)) return 'package'
|
|
140
|
+
return normalizeSearchText(value).replace(/\s+/g, '')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const normalizeCameraActionType = value => {
|
|
144
|
+
const raw = normalizeSearchText(value).replace(/\s+/g, '_')
|
|
145
|
+
if (['snapshot', 'get_snapshot', 'send_snapshot', 'camera_snapshot'].includes(raw)) return 'snapshot'
|
|
146
|
+
if (['analyze', 'analyse', 'analyze_snapshot', 'describe_snapshot', 'vision'].includes(raw)) return 'analyze'
|
|
147
|
+
if (['watch', 'subscribe', 'notify', 'create_watch'].includes(raw)) return 'watch'
|
|
148
|
+
if (['unwatch', 'unsubscribe', 'stop', 'remove_watch', 'delete_watch'].includes(raw)) return 'unwatch'
|
|
149
|
+
if (['list', 'list_watches', 'show_watches'].includes(raw)) return 'list_watches'
|
|
150
|
+
return ''
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const normalizeKnxAiCameraAction = (value, cameras = []) => {
|
|
154
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
155
|
+
const type = normalizeCameraActionType(value.type || value.action || value.operation)
|
|
156
|
+
if (!type) return null
|
|
157
|
+
const target = clampText(value.cameraId || value.cameraName || value.camera || value.target, 240)
|
|
158
|
+
const resolved = resolveKnxAiCamera({ target, cameras })
|
|
159
|
+
const camera = resolved.camera
|
|
160
|
+
let eventType = normalizeCameraEventType(value.eventType || value.event)
|
|
161
|
+
const requestedScope = clampText(value.scopeId || value.scopeName || value.zoneId || value.lineId || value.zone || value.line, 240)
|
|
162
|
+
const availableScopes = camera
|
|
163
|
+
? eventType === 'smartDetectLine'
|
|
164
|
+
? camera.lines
|
|
165
|
+
: ['smartDetectZone', 'smartDetectLoiterZone'].includes(eventType)
|
|
166
|
+
? camera.zones
|
|
167
|
+
: []
|
|
168
|
+
: []
|
|
169
|
+
const normalizedScopeTarget = normalizeSearchText(requestedScope)
|
|
170
|
+
const matchingScopes = normalizedScopeTarget
|
|
171
|
+
? (Array.isArray(availableScopes) ? availableScopes : []).filter(scope => [scope && scope.id, scope && scope.name]
|
|
172
|
+
.some(candidate => normalizeSearchText(candidate) === normalizedScopeTarget))
|
|
173
|
+
: []
|
|
174
|
+
const resolvedScope = matchingScopes.length === 1 ? matchingScopes[0] : null
|
|
175
|
+
const objectTypes = uniqueTexts(Array.isArray(value.objectTypes)
|
|
176
|
+
? value.objectTypes
|
|
177
|
+
: [value.objectType || value.smartDetectType]).map(normalizeCameraObjectType).filter(Boolean).slice(0, 12)
|
|
178
|
+
// A plain motion event has no classified object metadata. Convert requests
|
|
179
|
+
// such as "motion caused by a person/animal" to the generic smart-detection
|
|
180
|
+
// family so they can match Protect zone, line, or loiter detections.
|
|
181
|
+
if (type === 'watch' && eventType === 'motion' && objectTypes.length > 0) eventType = 'smartDetect'
|
|
182
|
+
return {
|
|
183
|
+
type,
|
|
184
|
+
cameraId: camera ? camera.id : clampText(value.cameraId, 160),
|
|
185
|
+
cameraName: camera ? camera.name : clampText(value.cameraName || value.camera || value.target, 240),
|
|
186
|
+
unresolvedTarget: camera ? '' : target,
|
|
187
|
+
unresolved: Boolean(target && !camera),
|
|
188
|
+
ambiguous: resolved.ambiguous,
|
|
189
|
+
eventType,
|
|
190
|
+
scopeId: resolvedScope ? clampText(resolvedScope.id, 160) : clampText(value.scopeId || value.zoneId || value.lineId, 160),
|
|
191
|
+
scopeName: resolvedScope ? clampText(resolvedScope.name || resolvedScope.id, 240) : clampText(value.scopeName || value.zone || value.line, 240),
|
|
192
|
+
unresolvedScope: Boolean(requestedScope && camera && !resolvedScope),
|
|
193
|
+
ambiguousScope: matchingScopes.length > 1,
|
|
194
|
+
objectTypes,
|
|
195
|
+
cooldownSeconds: Math.max(10, Math.min(86400, Number(value.cooldownSeconds) || 60)),
|
|
196
|
+
sendSnapshot: value.sendSnapshot !== false,
|
|
197
|
+
reason: clampText(value.reason, 500)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const normalizeKnxAiCameraActions = ({ actions, cameras } = {}) => (Array.isArray(actions) ? actions : [])
|
|
202
|
+
.slice(0, KNX_AI_CAMERA_MAX_ACTIONS)
|
|
203
|
+
.map(action => normalizeKnxAiCameraAction(action, cameras))
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
|
|
206
|
+
const normalizeKnxAiCameraEvent = value => {
|
|
207
|
+
const meta = value && typeof value === 'object' && !Array.isArray(value) && value.eventType
|
|
208
|
+
? value
|
|
209
|
+
: null
|
|
210
|
+
if (!meta) return null
|
|
211
|
+
return {
|
|
212
|
+
cameraId: clampText(meta.cameraId, 160),
|
|
213
|
+
cameraName: clampText(meta.cameraName, 240),
|
|
214
|
+
eventType: normalizeCameraEventType(meta.eventType),
|
|
215
|
+
scopeId: clampText(meta.scopeId, 160),
|
|
216
|
+
scopeName: clampText(meta.scopeName, 240),
|
|
217
|
+
objectTypes: uniqueTexts(Array.isArray(meta.objectTypes) ? meta.objectTypes : []).map(normalizeSearchText).filter(Boolean),
|
|
218
|
+
eventId: clampText(meta.eventId, 160),
|
|
219
|
+
active: meta.active !== false,
|
|
220
|
+
at: clampText(meta.at || new Date().toISOString(), 64),
|
|
221
|
+
raw: meta.raw
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const cameraWatchMatchesEvent = (watch, event) => {
|
|
226
|
+
if (!watch || !event) return false
|
|
227
|
+
const watchCameraId = clampText(watch.cameraId, 160)
|
|
228
|
+
const eventCameraId = clampText(event.cameraId, 160)
|
|
229
|
+
if (watchCameraId && eventCameraId && watchCameraId !== eventCameraId) return false
|
|
230
|
+
if ((!watchCameraId || !eventCameraId) && normalizeSearchText(watch.cameraName) !== normalizeSearchText(event.cameraName)) return false
|
|
231
|
+
const wantedObjects = (Array.isArray(watch.objectTypes) ? watch.objectTypes : []).map(normalizeCameraObjectType).filter(Boolean)
|
|
232
|
+
const watchEventType = normalizeCameraEventType(watch.eventType)
|
|
233
|
+
const eventType = normalizeCameraEventType(event.eventType)
|
|
234
|
+
const genericSmartDetection = watchEventType === 'smartDetect' ||
|
|
235
|
+
(watchEventType === 'motion' && wantedObjects.length > 0)
|
|
236
|
+
if (genericSmartDetection) {
|
|
237
|
+
if (!['smartDetectZone', 'smartDetectLine', 'smartDetectLoiterZone'].includes(eventType)) return false
|
|
238
|
+
} else if (watchEventType && watchEventType !== eventType) return false
|
|
239
|
+
const watchScope = normalizeSearchText(watch.scopeId || watch.scopeName)
|
|
240
|
+
const eventScopeCandidates = [event.scopeId, event.scopeName].map(normalizeSearchText).filter(Boolean)
|
|
241
|
+
if (watchScope && !eventScopeCandidates.some(candidate => candidate === watchScope || candidate.includes(watchScope) || watchScope.includes(candidate))) return false
|
|
242
|
+
if (wantedObjects.length) {
|
|
243
|
+
const detectedObjects = (Array.isArray(event.objectTypes) ? event.objectTypes : []).map(normalizeCameraObjectType).filter(Boolean)
|
|
244
|
+
if (!detectedObjects.some(type => wantedObjects.includes(type))) return false
|
|
245
|
+
}
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const normalizeKnxAiCameraImage = ({ data, mediaType } = {}) => {
|
|
250
|
+
if (!Buffer.isBuffer(data)) throw new Error('The camera snapshot is not binary image data')
|
|
251
|
+
if (data.length === 0) throw new Error('The camera snapshot is empty')
|
|
252
|
+
if (data.length > KNX_AI_CAMERA_IMAGE_MAX_BYTES) throw new Error(`The camera snapshot exceeds ${KNX_AI_CAMERA_IMAGE_MAX_BYTES} bytes`)
|
|
253
|
+
const type = clampText(mediaType || 'image/jpeg', 80).toLocaleLowerCase().split(';')[0]
|
|
254
|
+
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(type)) throw new Error(`Unsupported camera image type: ${type}`)
|
|
255
|
+
return { data, mediaType: type, bytes: data.length }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const buildKnxAiCameraNotificationText = ({ language, event } = {}) => {
|
|
259
|
+
const lang = clampText(language || 'en', 8).toLocaleLowerCase()
|
|
260
|
+
const camera = clampText(event && event.cameraName, 240) || clampText(event && event.cameraId, 160) || 'camera'
|
|
261
|
+
const eventType = normalizeCameraEventType(event && event.eventType)
|
|
262
|
+
const scope = clampText(event && (event.scopeName || event.scopeId), 240)
|
|
263
|
+
const objectTypes = uniqueTexts(event && event.objectTypes).join(', ')
|
|
264
|
+
const details = [scope, objectTypes].filter(Boolean).join(' — ')
|
|
265
|
+
const suffix = details ? ` (${details})` : ''
|
|
266
|
+
const labels = {
|
|
267
|
+
it: { smartDetectLine: 'un attraversamento di linea', smartDetectZone: 'un ingresso in zona', smartDetectLoiterZone: 'una permanenza in zona', motion: 'un movimento', ring: 'un suono del campanello', smartAudioDetect: 'un evento audio intelligente' },
|
|
268
|
+
de: { smartDetectLine: 'eine Linienüberquerung', smartDetectZone: 'einen Zoneneintritt', smartDetectLoiterZone: 'einen längeren Zonenaufenthalt', motion: 'eine Bewegung', ring: 'ein Klingeln', smartAudioDetect: 'ein intelligentes Audioereignis' },
|
|
269
|
+
fr: { smartDetectLine: 'un franchissement de ligne', smartDetectZone: 'une entrée dans une zone', smartDetectLoiterZone: 'une présence prolongée dans une zone', motion: 'un mouvement', ring: 'une sonnerie', smartAudioDetect: 'un événement audio intelligent' },
|
|
270
|
+
es: { smartDetectLine: 'un cruce de línea', smartDetectZone: 'una entrada en zona', smartDetectLoiterZone: 'una permanencia en zona', motion: 'un movimiento', ring: 'una llamada al timbre', smartAudioDetect: 'un evento de audio inteligente' },
|
|
271
|
+
zh: { smartDetectLine: '越线事件', smartDetectZone: '进入区域事件', smartDetectLoiterZone: '区域徘徊事件', motion: '移动事件', ring: '门铃事件', smartAudioDetect: '智能音频事件' },
|
|
272
|
+
en: { smartDetectLine: 'a line crossing', smartDetectZone: 'a zone entry', smartDetectLoiterZone: 'loitering in a zone', motion: 'motion', ring: 'a doorbell ring', smartAudioDetect: 'a smart audio event' }
|
|
273
|
+
}
|
|
274
|
+
const label = (labels[lang] || labels.en)[eventType] || (lang === 'zh' ? '摄像机事件' : lang === 'it' ? 'un evento' : lang === 'de' ? 'ein Ereignis' : lang === 'fr' ? 'un événement' : lang === 'es' ? 'un evento' : 'an event')
|
|
275
|
+
const copy = {
|
|
276
|
+
it: `La telecamera ${camera} ha rilevato ${label}${suffix}.`,
|
|
277
|
+
de: `Die Kamera ${camera} hat ${label} erkannt${suffix}.`,
|
|
278
|
+
fr: `La caméra ${camera} a détecté ${label}${suffix}.`,
|
|
279
|
+
es: `La cámara ${camera} ha detectado ${label}${suffix}.`,
|
|
280
|
+
zh: `摄像机 ${camera} 检测到${label}${suffix}。`,
|
|
281
|
+
en: `Camera ${camera} detected ${label}${suffix}.`
|
|
282
|
+
}
|
|
283
|
+
return copy[lang] || copy.en
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
KNX_AI_CAMERA_IMAGE_MAX_BYTES,
|
|
288
|
+
KNX_AI_CAMERA_MAX_ACTIONS,
|
|
289
|
+
KNX_AI_CAMERA_REGISTRY_KEY,
|
|
290
|
+
buildKnxAiCameraNotificationText,
|
|
291
|
+
cameraWatchMatchesEvent,
|
|
292
|
+
getKnxAiCameraAdapterRegistry,
|
|
293
|
+
normalizeCameraEventType,
|
|
294
|
+
normalizeCameraObjectType,
|
|
295
|
+
normalizeKnxAiCameraAction,
|
|
296
|
+
normalizeKnxAiCameraActions,
|
|
297
|
+
normalizeKnxAiCameraEvent,
|
|
298
|
+
normalizeKnxAiCameraImage,
|
|
299
|
+
normalizeKnxAiCameraRegistration,
|
|
300
|
+
normalizeSearchText,
|
|
301
|
+
resolveKnxAiCamera
|
|
302
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
const KNX_AI_CATALOG_MAX_RESEARCH_ROUNDS = 1
|
|
2
|
+
const KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND = 4
|
|
3
|
+
const KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION = 12
|
|
4
|
+
const KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS = 24
|
|
5
|
+
|
|
6
|
+
const normalizeText = (value) => String(value || '')
|
|
7
|
+
.normalize('NFKD')
|
|
8
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^\p{L}\p{N}/]+/gu, ' ')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim()
|
|
13
|
+
|
|
14
|
+
const normalizeGa = (value) => String(value || '').trim()
|
|
15
|
+
|
|
16
|
+
const clampLimit = (value) => Math.max(1, Math.min(
|
|
17
|
+
KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION,
|
|
18
|
+
Math.round(Number(value) || 8)
|
|
19
|
+
))
|
|
20
|
+
|
|
21
|
+
const normalizeStringList = (value, maxItems = 12) => Array.from(new Set(
|
|
22
|
+
(Array.isArray(value) ? value : [])
|
|
23
|
+
.map(item => String(item || '').trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
)).slice(0, maxItems)
|
|
26
|
+
|
|
27
|
+
const normalizeKnxAiCatalogActions = (value, { maxActions = KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND } = {}) => {
|
|
28
|
+
const accepted = []
|
|
29
|
+
const allowedOperations = new Set(['search', 'get', 'list_areas', 'browse_area', 'related'])
|
|
30
|
+
const allowedAccess = new Set(['any', 'read-only', 'read-write'])
|
|
31
|
+
const allowedPurposes = new Set(['any', 'read', 'write', 'inspect'])
|
|
32
|
+
;(Array.isArray(value) ? value : []).slice(0, Math.max(1, Number(maxActions) || 1)).forEach(candidate => {
|
|
33
|
+
const source = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : {}
|
|
34
|
+
const operation = String(source.operation || '').trim().toLowerCase()
|
|
35
|
+
if (!allowedOperations.has(operation)) return
|
|
36
|
+
const query = String(source.query || '').trim().slice(0, 300)
|
|
37
|
+
const destinations = normalizeStringList(source.destinations, 20)
|
|
38
|
+
const area = String(source.area || '').trim().slice(0, 300)
|
|
39
|
+
if (operation === 'search' && !query) return
|
|
40
|
+
if ((operation === 'get' || operation === 'related') && !destinations.length) return
|
|
41
|
+
if (operation === 'browse_area' && !area) return
|
|
42
|
+
const semanticKinds = normalizeStringList(source.semanticKinds, 12)
|
|
43
|
+
.map(kind => normalizeText(kind))
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
const requestedAccess = String(source.access || '').trim().toLowerCase()
|
|
46
|
+
const requestedPurpose = String(source.purpose || '').trim().toLowerCase()
|
|
47
|
+
accepted.push({
|
|
48
|
+
operation,
|
|
49
|
+
query,
|
|
50
|
+
destinations,
|
|
51
|
+
area,
|
|
52
|
+
semanticKinds,
|
|
53
|
+
access: allowedAccess.has(requestedAccess) ? requestedAccess : 'any',
|
|
54
|
+
purpose: allowedPurposes.has(requestedPurpose) ? requestedPurpose : 'any',
|
|
55
|
+
offset: Math.max(0, Math.min(100000, Math.round(Number(source.offset) || 0))),
|
|
56
|
+
limit: clampLimit(source.limit),
|
|
57
|
+
reason: String(source.reason || '').trim().slice(0, 1000)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
return accepted
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const editDistanceAtMostTwo = (left, right) => {
|
|
64
|
+
if (left === right) return 0
|
|
65
|
+
if (!left || !right || Math.abs(left.length - right.length) > 2) return 3
|
|
66
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index)
|
|
67
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
68
|
+
const current = [i]
|
|
69
|
+
let rowMinimum = current[0]
|
|
70
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
71
|
+
const substitution = previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1)
|
|
72
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, substitution)
|
|
73
|
+
rowMinimum = Math.min(rowMinimum, current[j])
|
|
74
|
+
}
|
|
75
|
+
if (rowMinimum > 2) return 3
|
|
76
|
+
previous = current
|
|
77
|
+
}
|
|
78
|
+
return previous[right.length]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tokenQuality = (queryToken, candidateToken) => {
|
|
82
|
+
if (queryToken === candidateToken) return 1
|
|
83
|
+
const substringMinimum = /[^\u0000-\u007f]/.test(queryToken) ? 2 : 3
|
|
84
|
+
if (queryToken.length >= substringMinimum && (candidateToken.includes(queryToken) || queryToken.includes(candidateToken))) return 0.82
|
|
85
|
+
if (queryToken.length < 4) return 0
|
|
86
|
+
const distance = editDistanceAtMostTwo(queryToken, candidateToken)
|
|
87
|
+
if (distance === 1) return 0.68
|
|
88
|
+
if (distance === 2 && queryToken.length >= 7) return 0.5
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const buildSearchDocument = (item) => {
|
|
93
|
+
const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
94
|
+
const aliases = Array.isArray(item && item.aliases) ? item.aliases : []
|
|
95
|
+
const options = Array.isArray(item && item.valueOptions) ? item.valueOptions : []
|
|
96
|
+
const fields = {
|
|
97
|
+
ga: normalizeText(item && item.ga),
|
|
98
|
+
label: normalizeText(item && item.label),
|
|
99
|
+
etsName: normalizeText(item && item.etsName),
|
|
100
|
+
hierarchy: normalizeText(item && item.hierarchyPath),
|
|
101
|
+
aliases: normalizeText(aliases.join(' ')),
|
|
102
|
+
semantic: normalizeText([semantic.kind, semantic.area, item && item.mainGroup, item && item.middleGroup, ...(Array.isArray(item && item.tags) ? item.tags : [])].join(' ')),
|
|
103
|
+
values: normalizeText(options.map(option => `${option && option.value} ${option && option.label}`).join(' ')),
|
|
104
|
+
dpt: normalizeText(item && item.dpt)
|
|
105
|
+
}
|
|
106
|
+
const combined = Object.values(fields).join(' ').trim()
|
|
107
|
+
return { fields, combined, tokens: Array.from(new Set(combined.split(' ').filter(Boolean))) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const itemPassesFilters = (item, action) => {
|
|
111
|
+
if (action.access === 'read-only' && item && item.readOnly !== true) return false
|
|
112
|
+
if (action.access === 'read-write' && item && item.readOnly === true) return false
|
|
113
|
+
if (action.purpose === 'write' && item && item.readOnly === true) return false
|
|
114
|
+
const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
115
|
+
if (action.semanticKinds.length && !action.semanticKinds.includes(normalizeText(semantic.kind))) return false
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const purposeScore = (item, purpose) => {
|
|
120
|
+
const readOnly = item && item.readOnly === true
|
|
121
|
+
if (purpose === 'write') return readOnly ? -1000 : 130
|
|
122
|
+
if (purpose === 'read') return readOnly ? 90 : 50
|
|
123
|
+
if (purpose === 'inspect') return readOnly ? 70 : 55
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const scoreSearchMatch = (item, action) => {
|
|
128
|
+
if (!itemPassesFilters(item, action)) return 0
|
|
129
|
+
const query = normalizeText(action.query)
|
|
130
|
+
if (!query) return 0
|
|
131
|
+
const document = buildSearchDocument(item)
|
|
132
|
+
const exactGa = normalizeGa(item && item.ga) === normalizeGa(action.query)
|
|
133
|
+
if (exactGa) return 2000 + purposeScore(item, action.purpose)
|
|
134
|
+
let score = 0
|
|
135
|
+
if (document.fields.label === query) score += 900
|
|
136
|
+
if (document.fields.etsName === query) score += 850
|
|
137
|
+
if (document.fields.aliases.split(' ').includes(query)) score += 800
|
|
138
|
+
if (document.fields.hierarchy === query) score += 750
|
|
139
|
+
if (document.combined.includes(query)) score += 360
|
|
140
|
+
const queryTokens = query.split(' ').filter(Boolean)
|
|
141
|
+
let qualityTotal = 0
|
|
142
|
+
let matchedTokens = 0
|
|
143
|
+
let strongMatches = 0
|
|
144
|
+
for (const queryToken of queryTokens) {
|
|
145
|
+
let best = 0
|
|
146
|
+
for (const candidateToken of document.tokens) {
|
|
147
|
+
best = Math.max(best, tokenQuality(queryToken, candidateToken))
|
|
148
|
+
if (best === 1) break
|
|
149
|
+
}
|
|
150
|
+
if (best >= 0.5) {
|
|
151
|
+
matchedTokens += 1
|
|
152
|
+
qualityTotal += best
|
|
153
|
+
if (best >= 0.68) strongMatches += 1
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (strongMatches === 0) return 0
|
|
157
|
+
const missingTokens = Math.max(0, queryTokens.length - matchedTokens)
|
|
158
|
+
const coverage = queryTokens.length > 0 ? matchedTokens / queryTokens.length : 0
|
|
159
|
+
score += qualityTotal * 120
|
|
160
|
+
score += coverage * 200
|
|
161
|
+
score += Math.max(0, matchedTokens - 1) * 50
|
|
162
|
+
score -= missingTokens * 18
|
|
163
|
+
score += purposeScore(item, action.purpose)
|
|
164
|
+
if (action.area) {
|
|
165
|
+
const area = normalizeText(action.area)
|
|
166
|
+
const itemArea = normalizeText([
|
|
167
|
+
item && item.hierarchyPath,
|
|
168
|
+
item && item.mainGroup,
|
|
169
|
+
item && item.middleGroup,
|
|
170
|
+
item && item.semantic && item.semantic.area
|
|
171
|
+
].join(' '))
|
|
172
|
+
if (!itemArea.includes(area)) return 0
|
|
173
|
+
score += 120
|
|
174
|
+
}
|
|
175
|
+
return score
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const sortScoredItems = (scored) => scored
|
|
179
|
+
.sort((left, right) => right.score - left.score || String(left.item && left.item.hierarchyPath || left.item && left.item.label || left.item && left.item.ga || '').localeCompare(String(right.item && right.item.hierarchyPath || right.item && right.item.label || right.item && right.item.ga || '')))
|
|
180
|
+
.map(entry => entry.item)
|
|
181
|
+
|
|
182
|
+
const findSearchItems = (catalog, action) => sortScoredItems(
|
|
183
|
+
catalog.map(item => ({ item, score: scoreSearchMatch(item, action) })).filter(entry => entry.score > 0)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const buildAreaRows = (catalog) => {
|
|
187
|
+
const areas = new Map()
|
|
188
|
+
catalog.forEach(item => {
|
|
189
|
+
const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
190
|
+
const candidates = [semantic.area, item && item.hierarchyPath, item && item.middleGroup, item && item.mainGroup]
|
|
191
|
+
.map(value => String(value || '').trim())
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
const area = candidates[0] || 'Unclassified'
|
|
194
|
+
const key = normalizeText(area)
|
|
195
|
+
if (!key) return
|
|
196
|
+
if (!areas.has(key)) areas.set(key, { area, count: 0, sampleLabels: [] })
|
|
197
|
+
const row = areas.get(key)
|
|
198
|
+
row.count += 1
|
|
199
|
+
const label = String(item && item.label || item && item.ga || '').trim()
|
|
200
|
+
if (label && row.sampleLabels.length < 3 && !row.sampleLabels.includes(label)) row.sampleLabels.push(label)
|
|
201
|
+
})
|
|
202
|
+
return Array.from(areas.values()).sort((left, right) => right.count - left.count || left.area.localeCompare(right.area))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const findAreaItems = (catalog, action) => {
|
|
206
|
+
const requestedArea = normalizeText(action.area)
|
|
207
|
+
return sortScoredItems(catalog
|
|
208
|
+
.filter(item => itemPassesFilters(item, action))
|
|
209
|
+
.map(item => {
|
|
210
|
+
const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
211
|
+
const areaText = normalizeText([semantic.area, item && item.hierarchyPath, item && item.middleGroup, item && item.mainGroup].join(' '))
|
|
212
|
+
if (!areaText.includes(requestedArea)) return { item, score: 0 }
|
|
213
|
+
return { item, score: 400 + purposeScore(item, action.purpose) }
|
|
214
|
+
})
|
|
215
|
+
.filter(entry => entry.score > 0))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const findRelatedItems = (catalog, action) => {
|
|
219
|
+
const requested = new Set(action.destinations.map(normalizeGa))
|
|
220
|
+
const seeds = catalog.filter(item => requested.has(normalizeGa(item && item.ga)))
|
|
221
|
+
if (!seeds.length) return []
|
|
222
|
+
const seedDocuments = seeds.map(seed => ({ seed, tokens: new Set(buildSearchDocument(seed).tokens) }))
|
|
223
|
+
const scored = catalog.map(item => {
|
|
224
|
+
if (!itemPassesFilters(item, action)) return { item, score: 0 }
|
|
225
|
+
let best = requested.has(normalizeGa(item && item.ga)) ? 1000 : 0
|
|
226
|
+
const itemTokens = buildSearchDocument(item).tokens
|
|
227
|
+
seedDocuments.forEach(({ seed, tokens: seedTokens }) => {
|
|
228
|
+
if (seed === item) return
|
|
229
|
+
const seedSemantic = seed && seed.semantic && typeof seed.semantic === 'object' ? seed.semantic : {}
|
|
230
|
+
const itemSemantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
231
|
+
let relationScore = 0
|
|
232
|
+
if (normalizeText(seed.hierarchyPath) && normalizeText(seed.hierarchyPath) === normalizeText(item.hierarchyPath)) relationScore += 260
|
|
233
|
+
if (normalizeText(seedSemantic.area) && normalizeText(seedSemantic.area) === normalizeText(itemSemantic.area)) relationScore += 180
|
|
234
|
+
if (normalizeText(seed.middleGroup) && normalizeText(seed.middleGroup) === normalizeText(item.middleGroup)) relationScore += 100
|
|
235
|
+
if (normalizeText(seed.mainGroup) && normalizeText(seed.mainGroup) === normalizeText(item.mainGroup)) relationScore += 45
|
|
236
|
+
if (normalizeText(seedSemantic.kind) && normalizeText(seedSemantic.kind) === normalizeText(itemSemantic.kind)) relationScore += 65
|
|
237
|
+
const overlap = itemTokens.filter(token => token.length >= 4 && seedTokens.has(token)).length
|
|
238
|
+
relationScore += Math.min(100, overlap * 20)
|
|
239
|
+
if (relationScore <= 0) return
|
|
240
|
+
const score = relationScore + purposeScore(item, action.purpose)
|
|
241
|
+
best = Math.max(best, score)
|
|
242
|
+
})
|
|
243
|
+
return { item, score: best }
|
|
244
|
+
}).filter(entry => entry.score > 0)
|
|
245
|
+
return sortScoredItems(scored)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const actionFingerprint = (action) => JSON.stringify({
|
|
249
|
+
operation: action.operation,
|
|
250
|
+
query: normalizeText(action.query),
|
|
251
|
+
destinations: action.destinations.map(normalizeGa).sort(),
|
|
252
|
+
area: normalizeText(action.area),
|
|
253
|
+
semanticKinds: action.semanticKinds.slice().sort(),
|
|
254
|
+
access: action.access,
|
|
255
|
+
purpose: action.purpose,
|
|
256
|
+
offset: action.offset,
|
|
257
|
+
limit: action.limit
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const executeKnxAiCatalogActions = ({ actions, catalog, priorResults = [] } = {}) => {
|
|
261
|
+
const safeCatalog = Array.isArray(catalog) ? catalog : []
|
|
262
|
+
const normalized = normalizeKnxAiCatalogActions(actions)
|
|
263
|
+
const seen = new Set((Array.isArray(priorResults) ? priorResults : []).map(result => String(result && result.fingerprint || '')).filter(Boolean))
|
|
264
|
+
const results = []
|
|
265
|
+
normalized.forEach(action => {
|
|
266
|
+
const fingerprint = actionFingerprint(action)
|
|
267
|
+
if (seen.has(fingerprint)) return
|
|
268
|
+
seen.add(fingerprint)
|
|
269
|
+
if (action.operation === 'list_areas') {
|
|
270
|
+
const allAreas = buildAreaRows(safeCatalog.filter(item => itemPassesFilters(item, action)))
|
|
271
|
+
results.push({
|
|
272
|
+
action,
|
|
273
|
+
fingerprint,
|
|
274
|
+
ok: true,
|
|
275
|
+
totalMatches: allAreas.length,
|
|
276
|
+
truncated: allAreas.length > action.offset + action.limit,
|
|
277
|
+
areas: allAreas.slice(action.offset, action.offset + action.limit),
|
|
278
|
+
items: []
|
|
279
|
+
})
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
let matched = []
|
|
283
|
+
if (action.operation === 'get') {
|
|
284
|
+
const requested = new Set(action.destinations.map(normalizeGa))
|
|
285
|
+
matched = safeCatalog.filter(item => requested.has(normalizeGa(item && item.ga)) && itemPassesFilters(item, action))
|
|
286
|
+
} else if (action.operation === 'search') {
|
|
287
|
+
matched = findSearchItems(safeCatalog, action)
|
|
288
|
+
} else if (action.operation === 'browse_area') {
|
|
289
|
+
matched = findAreaItems(safeCatalog, action)
|
|
290
|
+
} else if (action.operation === 'related') {
|
|
291
|
+
matched = findRelatedItems(safeCatalog, action)
|
|
292
|
+
}
|
|
293
|
+
results.push({
|
|
294
|
+
action,
|
|
295
|
+
fingerprint,
|
|
296
|
+
ok: true,
|
|
297
|
+
totalMatches: matched.length,
|
|
298
|
+
truncated: matched.length > action.offset + action.limit,
|
|
299
|
+
items: matched.slice(action.offset, action.offset + action.limit),
|
|
300
|
+
areas: []
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
return results
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const collectKnxAiCatalogObjects = (results, maxItems = KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS) => {
|
|
307
|
+
const byGa = new Map()
|
|
308
|
+
;(Array.isArray(results) ? results.slice().reverse() : []).forEach(result => {
|
|
309
|
+
;(Array.isArray(result && result.items) ? result.items : []).forEach(item => {
|
|
310
|
+
const ga = normalizeGa(item && item.ga)
|
|
311
|
+
if (!ga || byGa.has(ga) || byGa.size >= maxItems) return
|
|
312
|
+
byGa.set(ga, item)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
return Array.from(byGa.values())
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const buildKnxAiCatalogResearchContext = (results) => {
|
|
319
|
+
const source = Array.isArray(results) ? results : []
|
|
320
|
+
if (!source.length) return ''
|
|
321
|
+
const lines = ['LOCAL ETS CATALOG RETRIEVAL RESULTS:']
|
|
322
|
+
source.forEach((result, index) => {
|
|
323
|
+
const action = result && result.action ? result.action : {}
|
|
324
|
+
const target = action.operation === 'search'
|
|
325
|
+
? ` query=${JSON.stringify(action.query)}`
|
|
326
|
+
: action.operation === 'browse_area'
|
|
327
|
+
? ` area=${JSON.stringify(action.area)}`
|
|
328
|
+
: (action.operation === 'get' || action.operation === 'related')
|
|
329
|
+
? ` destinations=${JSON.stringify(action.destinations)}`
|
|
330
|
+
: ''
|
|
331
|
+
lines.push(`[C${index + 1}] ${action.operation || 'unknown'}${target}: ${Number(result && result.totalMatches) || 0} match(es); page offset=${Math.max(0, Number(action.offset) || 0)}, limit=${Math.max(1, Number(action.limit) || 1)}${result && result.truncated ? '; more matches are available with a larger offset' : ''}.`)
|
|
332
|
+
;(Array.isArray(result && result.areas) ? result.areas : []).forEach(area => {
|
|
333
|
+
lines.push(`- area ${JSON.stringify(area.area)} | ${area.count} objects | examples ${area.sampleLabels.join(', ') || '?'}`)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
lines.push('END LOCAL ETS CATALOG RETRIEVAL RESULTS')
|
|
337
|
+
return lines.join('\n')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = {
|
|
341
|
+
KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS,
|
|
342
|
+
KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND,
|
|
343
|
+
KNX_AI_CATALOG_MAX_RESEARCH_ROUNDS,
|
|
344
|
+
KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION,
|
|
345
|
+
buildKnxAiCatalogResearchContext,
|
|
346
|
+
collectKnxAiCatalogObjects,
|
|
347
|
+
executeKnxAiCatalogActions,
|
|
348
|
+
normalizeKnxAiCatalogActions
|
|
349
|
+
}
|