node-red-contrib-knx-ultimate 6.3.29 → 6.3.32
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 +16 -0
- package/nodes/knxUltimateAI.html +191 -77
- package/nodes/knxUltimateAI.js +1119 -1552
- package/nodes/locales/de/knxUltimateAI.html +20 -17
- package/nodes/locales/de/knxUltimateAI.json +35 -29
- package/nodes/locales/en/knxUltimateAI.html +18 -17
- package/nodes/locales/en/knxUltimateAI.json +35 -29
- package/nodes/locales/es/knxUltimateAI.html +20 -17
- package/nodes/locales/es/knxUltimateAI.json +35 -29
- package/nodes/locales/fr/knxUltimateAI.html +20 -17
- package/nodes/locales/fr/knxUltimateAI.json +35 -29
- package/nodes/locales/it/knxUltimateAI.html +18 -17
- package/nodes/locales/it/knxUltimateAI.json +35 -29
- package/nodes/locales/zh-CN/knxUltimateAI.html +20 -17
- package/nodes/locales/zh-CN/knxUltimateAI.json +35 -29
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +4 -4
- package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
- package/nodes/utils/knxAiChatContext.js +43 -1
- package/nodes/utils/knxAiHomeMemory.js +15 -7
- package/nodes/utils/knxAiScheduler.js +4 -1
- package/nodes/utils/knxAiSemanticContext.js +662 -0
- package/nodes/utils/knxAiTelegramVoice.js +74 -4
- package/package.json +2 -2
- package/resources/KNXAIChatAdapterMappings.js +60 -5
|
@@ -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
|
+
}
|
|
@@ -207,6 +207,47 @@ const getKnxAiChatSession = (context, sessionId) => {
|
|
|
207
207
|
const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
|
|
208
208
|
const session = getKnxAiChatSession(context, sessionId)
|
|
209
209
|
if (!session.instructions.length && !session.turns.length && !session.cameraWatches.length) return ''
|
|
210
|
+
const boundedChars = Number(maxChars) > 0 ? Math.max(1000, Number(maxChars)) : 0
|
|
211
|
+
if (boundedChars > 0) {
|
|
212
|
+
const fixedBlocks = []
|
|
213
|
+
if (session.instructions.length) {
|
|
214
|
+
const instructionBudget = Math.min(1200, Math.max(400, Math.floor(boundedChars * 0.3)))
|
|
215
|
+
const instructionLines = [
|
|
216
|
+
'PERSISTENT USER FACTS AND INSTRUCTIONS:',
|
|
217
|
+
...session.instructions.map(item => `- ${item.text.replace(/\r?\n/g, ' ')}`)
|
|
218
|
+
]
|
|
219
|
+
fixedBlocks.push(instructionLines.join('\n').slice(0, instructionBudget))
|
|
220
|
+
}
|
|
221
|
+
if (session.cameraWatches.length) {
|
|
222
|
+
const watchLines = [
|
|
223
|
+
'ACTIVE CAMERA WATCHES:',
|
|
224
|
+
...session.cameraWatches.map(watch => {
|
|
225
|
+
const camera = watch.cameraName || watch.cameraId
|
|
226
|
+
const scope = watch.scopeName || watch.scopeId
|
|
227
|
+
const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
|
|
228
|
+
return `- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`
|
|
229
|
+
})
|
|
230
|
+
]
|
|
231
|
+
fixedBlocks.push(watchLines.join('\n').slice(0, Math.min(800, Math.floor(boundedChars * 0.25))))
|
|
232
|
+
}
|
|
233
|
+
const fixedText = fixedBlocks.join('\n\n')
|
|
234
|
+
const turnBudget = Math.max(0, boundedChars - fixedText.length - (fixedText ? 2 : 0))
|
|
235
|
+
const selectedTurns = []
|
|
236
|
+
let used = 0
|
|
237
|
+
for (let index = session.turns.length - 1; index >= 0; index -= 1) {
|
|
238
|
+
const turn = session.turns[index]
|
|
239
|
+
const renderedTurn = `User: ${clampText(turn.question, 700)}\nAssistant: ${clampText(turn.reply, 1400)}`
|
|
240
|
+
const nextSize = renderedTurn.length + (selectedTurns.length ? 1 : 0)
|
|
241
|
+
if (used + nextSize > turnBudget) {
|
|
242
|
+
if (!selectedTurns.length && turnBudget >= 200) selectedTurns.unshift(renderedTurn.slice(0, turnBudget))
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
selectedTurns.unshift(renderedTurn)
|
|
246
|
+
used += nextSize
|
|
247
|
+
}
|
|
248
|
+
const conversationBlock = selectedTurns.length ? `RECENT CONVERSATION:\n${selectedTurns.join('\n')}` : ''
|
|
249
|
+
return [fixedText, conversationBlock].filter(Boolean).join('\n\n').slice(0, boundedChars)
|
|
250
|
+
}
|
|
210
251
|
const lines = []
|
|
211
252
|
if (session.instructions.length) {
|
|
212
253
|
lines.push('PERSISTENT USER-PROVIDED FACTS, PREFERENCES, AND INSTRUCTIONS (follow relevant entries unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
|
|
@@ -230,7 +271,8 @@ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } =
|
|
|
230
271
|
lines.push(`- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
|
|
231
272
|
})
|
|
232
273
|
}
|
|
233
|
-
|
|
274
|
+
const rendered = lines.join('\n')
|
|
275
|
+
return rendered
|
|
234
276
|
}
|
|
235
277
|
|
|
236
278
|
const escapeKnxAiChatContextField = value => String(value === undefined || value === null ? '' : value)
|
|
@@ -190,17 +190,16 @@ const inferKnxAiHomeSemantic = (item = {}) => {
|
|
|
190
190
|
item.hierarchyPath,
|
|
191
191
|
item.label,
|
|
192
192
|
item.etsName,
|
|
193
|
+
Array.isArray(item.aliases) ? item.aliases.join(' ') : '',
|
|
193
194
|
Array.isArray(item.tags) ? item.tags.join(' ') : ''
|
|
194
195
|
].filter(Boolean).join(' ')
|
|
195
196
|
const normalized = normalizeText(source)
|
|
196
197
|
const concept = CONCEPT_PATTERNS.find(entry => matches(normalized, entry.patterns))
|
|
197
198
|
const areaMatch = AREA_PATTERNS.find(entry => matches(normalized, entry.patterns))
|
|
198
|
-
const role = String(item.role || 'neutral').trim().toLowerCase()
|
|
199
199
|
const dpt = String(item.dpt || '').trim()
|
|
200
200
|
const semantic = {
|
|
201
201
|
kind: concept ? concept.kind : 'unknown',
|
|
202
202
|
area: areaMatch ? areaMatch.area : '',
|
|
203
|
-
role,
|
|
204
203
|
confidence: concept ? concept.confidence : 0,
|
|
205
204
|
sourceLanguage: /[\u3400-\u9fff]/.test(source) ? 'zh-CN' : 'auto',
|
|
206
205
|
originalLabel: clampText(item.label || item.etsName || item.ga || '', 240),
|
|
@@ -239,6 +238,16 @@ const normalizeArray = (value, maxItems) => {
|
|
|
239
238
|
.map(item => clonePlain(item, {}))
|
|
240
239
|
}
|
|
241
240
|
|
|
241
|
+
const normalizeSemanticObjects = (value) => normalizeArray(value, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
|
|
242
|
+
.map(item => ({
|
|
243
|
+
ga: clampText(item.ga, 32),
|
|
244
|
+
dpt: clampText(item.dpt, 32),
|
|
245
|
+
label: clampText(item.label, 240),
|
|
246
|
+
kind: clampText(item.kind, 80),
|
|
247
|
+
area: clampText(item.area, 80),
|
|
248
|
+
confidence: Number.isFinite(Number(item.confidence)) ? Number(item.confidence) : 0
|
|
249
|
+
}))
|
|
250
|
+
|
|
242
251
|
const normalizeKnxAiHomeMemory = (value = {}) => {
|
|
243
252
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
244
253
|
return {
|
|
@@ -250,7 +259,7 @@ const normalizeKnxAiHomeMemory = (value = {}) => {
|
|
|
250
259
|
observations: normalizeArray(source.observations, HOME_MEMORY_MAX_OBSERVATIONS),
|
|
251
260
|
habits: normalizeArray(source.habits, HOME_MEMORY_MAX_HABITS),
|
|
252
261
|
notifications: normalizeArray(source.notifications, HOME_MEMORY_MAX_NOTIFICATIONS),
|
|
253
|
-
semanticObjects:
|
|
262
|
+
semanticObjects: normalizeSemanticObjects(source.semanticObjects)
|
|
254
263
|
}
|
|
255
264
|
}
|
|
256
265
|
|
|
@@ -323,11 +332,11 @@ const buildKnxAiHomeMemoryMarkdown = ({ memory, maxKb } = {}) => {
|
|
|
323
332
|
'',
|
|
324
333
|
'## Semantic ETS model',
|
|
325
334
|
'',
|
|
326
|
-
'| GA | DPT | Kind | Area |
|
|
327
|
-
'
|
|
335
|
+
'| GA | DPT | Kind | Area | Confidence | ETS label |',
|
|
336
|
+
'|---|---|---|---|---:|---|'
|
|
328
337
|
]
|
|
329
338
|
bounded.semanticObjects.forEach(item => {
|
|
330
|
-
lines.push(`| ${escapeMarkdownCell(item.ga)} | ${escapeMarkdownCell(item.dpt)} | ${escapeMarkdownCell(item.kind)} | ${escapeMarkdownCell(item.area)} | ${
|
|
339
|
+
lines.push(`| ${escapeMarkdownCell(item.ga)} | ${escapeMarkdownCell(item.dpt)} | ${escapeMarkdownCell(item.kind)} | ${escapeMarkdownCell(item.area)} | ${Number(item.confidence || 0).toFixed(2)} | ${escapeMarkdownCell(item.label)} |`)
|
|
331
340
|
})
|
|
332
341
|
lines.push('', '## Learned habits', '')
|
|
333
342
|
if (!bounded.habits.length) lines.push('_No stable habit has been learned yet._')
|
|
@@ -382,7 +391,6 @@ const CLOSED_RE = /\b(closed|close|down|chiuso|chiusa|chiusi|chiuse|geschlossen|
|
|
|
382
391
|
const classifyKnxAiOpenState = ({ semantic, dpt, payload, valueOptions } = {}) => {
|
|
383
392
|
const safeSemantic = semantic && typeof semantic === 'object' ? semantic : {}
|
|
384
393
|
if (!['cover', 'window', 'door'].includes(safeSemantic.kind)) return null
|
|
385
|
-
if (String(safeSemantic.role || '').toLowerCase() === 'command') return null
|
|
386
394
|
const dptId = String(dpt || safeSemantic.dpt || '').trim()
|
|
387
395
|
const main = dptId.split('.')[0]
|
|
388
396
|
if (main === '5' && safeSemantic.kind === 'cover') {
|
|
@@ -385,7 +385,10 @@ const buildKnxAiSchedulePromptContext = (store, { sessionId, maxChars = 12000, n
|
|
|
385
385
|
const lines = tasks.map(task => {
|
|
386
386
|
return `${task.id} | kind ${task.kind} | ${task.title} | instruction ${JSON.stringify(task.instruction)} | next ${task.nextRunAt || '-'} | every ${task.intervalMinutes || 0} minute(s) | expires ${task.expiresAt || 'never'}`
|
|
387
387
|
})
|
|
388
|
-
|
|
388
|
+
const rendered = lines.join('\n')
|
|
389
|
+
return Number(maxChars) > 0
|
|
390
|
+
? clampText(rendered, Math.max(200, Number(maxChars)))
|
|
391
|
+
: rendered
|
|
389
392
|
}
|
|
390
393
|
|
|
391
394
|
const buildKnxAiScheduleMarkdown = (store, { now = Date.now() } = {}) => {
|