node-red-contrib-knx-ultimate 6.3.30 → 6.4.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/CHANGELOG.md +21 -0
- package/nodes/knxUltimateAI.html +210 -82
- package/nodes/knxUltimateAI.js +2554 -1660
- package/nodes/knxUltimateAIHomeAssistant.html +38 -0
- package/nodes/knxUltimateAIHomeAssistant.js +152 -0
- package/nodes/locales/de/knxUltimateAI.html +19 -16
- package/nodes/locales/de/knxUltimateAI.json +40 -31
- package/nodes/locales/en/knxUltimateAI.html +27 -18
- package/nodes/locales/en/knxUltimateAI.json +40 -31
- package/nodes/locales/es/knxUltimateAI.html +19 -16
- package/nodes/locales/es/knxUltimateAI.json +40 -31
- package/nodes/locales/fr/knxUltimateAI.html +19 -16
- package/nodes/locales/fr/knxUltimateAI.json +40 -31
- package/nodes/locales/it/knxUltimateAI.html +27 -18
- package/nodes/locales/it/knxUltimateAI.json +40 -31
- package/nodes/locales/zh-CN/knxUltimateAI.html +19 -16
- package/nodes/locales/zh-CN/knxUltimateAI.json +40 -31
- package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -4
- package/nodes/utils/knxAiCamera.js +4 -4
- package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
- package/nodes/utils/knxAiCerebrum.js +406 -0
- package/nodes/utils/knxAiChatContext.js +43 -1
- package/nodes/utils/knxAiHomeMemory.js +555 -13
- package/nodes/utils/knxAiScheduler.js +4 -1
- package/nodes/utils/knxAiSemanticContext.js +662 -0
- package/package.json +5 -3
- package/resources/KNXAIChatAdapterMappings.js +31 -5
- package/resources/hueControllerProfiles.js +7614 -7622
|
@@ -163,8 +163,8 @@ const normalizeKnxAiCameraAction = (value, cameras = []) => {
|
|
|
163
163
|
? eventType === 'smartDetectLine'
|
|
164
164
|
? camera.lines
|
|
165
165
|
: ['smartDetectZone', 'smartDetectLoiterZone'].includes(eventType)
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
? camera.zones
|
|
167
|
+
: []
|
|
168
168
|
: []
|
|
169
169
|
const normalizedScopeTarget = normalizeSearchText(requestedScope)
|
|
170
170
|
const matchingScopes = normalizedScopeTarget
|
|
@@ -231,8 +231,8 @@ const cameraWatchMatchesEvent = (watch, event) => {
|
|
|
231
231
|
const wantedObjects = (Array.isArray(watch.objectTypes) ? watch.objectTypes : []).map(normalizeCameraObjectType).filter(Boolean)
|
|
232
232
|
const watchEventType = normalizeCameraEventType(watch.eventType)
|
|
233
233
|
const eventType = normalizeCameraEventType(event.eventType)
|
|
234
|
-
const genericSmartDetection = watchEventType === 'smartDetect'
|
|
235
|
-
|
|
234
|
+
const genericSmartDetection = watchEventType === 'smartDetect' ||
|
|
235
|
+
(watchEventType === 'motion' && wantedObjects.length > 0)
|
|
236
236
|
if (genericSmartDetection) {
|
|
237
237
|
if (!['smartDetectZone', 'smartDetectLine', 'smartDetectLoiterZone'].includes(eventType)) return false
|
|
238
238
|
} else if (watchEventType && watchEventType !== eventType) return false
|
|
@@ -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
|
+
}
|