node-red-contrib-knx-ultimate
Version:
Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.
1,335 lines (1,242 loc) • 353 kB
JavaScript
// KNX Ultimate AI / Traffic Analyzer
const loggerClass = require('./utils/sysLogger')
const dptlib = require('knxultimate').dptlib
const fs = require('fs')
const path = require('path')
const { spawn } = require('child_process')
const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
let googleTranslateTTS = null
try {
googleTranslateTTS = require('google-translate-tts')
} catch (error) {
googleTranslateTTS = null
}
const coerceBoolean = (value) => (value === true || value === 'true')
let adminEndpointsRegistered = false
const aiRuntimeNodes = new Map()
const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue')
// ---------------------------------------------------------------------------
// KNX AI Flow Builder helpers
// Build a node "catalog" (type + editable fields) from this package's own
// editor .html files, so the LLM knows exactly which node types and config
// fields it can emit when generating a Node-RED flow to paste in the editor.
// ---------------------------------------------------------------------------
// Native Node-RED core nodes we explicitly allow in generated flows.
const KNX_AI_FLOW_CORE_NODES = [
{ type: 'tab', paletteLabel: 'Flow tab (do not emit, added automatically)', category: 'config', inputs: 0, outputs: 0, fields: {} },
{ type: 'inject', paletteLabel: 'inject (manual/scheduled trigger)', category: 'common', inputs: 0, outputs: 1, fields: { name: {}, props: {}, repeat: {}, crontab: {}, once: {}, topic: {}, payload: {}, payloadType: {} } },
{ type: 'debug', paletteLabel: 'debug (sidebar log)', category: 'common', inputs: 1, outputs: 0, fields: { name: {}, active: {}, complete: {}, console: {}, tosidebar: {} } },
{ type: 'function', paletteLabel: 'function (custom JavaScript)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, func: {}, outputs: {}, initialize: {}, finalize: {} } },
{ type: 'switch', paletteLabel: 'switch (route by rules)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, property: {}, propertyType: {}, rules: {}, outputs: {} } },
{ type: 'change', paletteLabel: 'change (set/move/delete properties)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, rules: {} } },
{ type: 'range', paletteLabel: 'range (scale a number)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, minin: {}, maxin: {}, minout: {}, maxout: {}, action: {}, round: {}, property: {} } },
{ type: 'delay', paletteLabel: 'delay (delay/rate limit)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, pauseType: {}, timeout: {}, timeoutUnits: {}, rate: {}, rateUnits: {} } },
{ type: 'trigger', paletteLabel: 'trigger (send-then-reset / debounce)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, op1: {}, op2: {}, duration: {}, units: {}, reset: {} } },
{ type: 'comment', paletteLabel: 'comment (annotation)', category: 'common', inputs: 0, outputs: 0, fields: { name: {}, info: {} } },
{ type: 'link in', paletteLabel: 'link in', category: 'common', inputs: 0, outputs: 1, fields: { name: {}, links: {} } },
{ type: 'link out', paletteLabel: 'link out', category: 'common', inputs: 1, outputs: 0, fields: { name: {}, links: {} } }
]
// Scan a JS object literal body (the text between its outer braces) and return,
// for each top-level key, the inner object text. String- and comment-aware so
// commented-out entries (e.g. "//buttonState: {value:true}") are ignored.
const knxAiScanObjectEntries = (body) => {
const entries = {}
const len = body.length
let i = 0
let depth = 0
let pendingKey = ''
let collecting = ''
let innerStart = -1
while (i < len) {
const c = body[i]
const next = body[i + 1]
if (c === '/' && next === '/') {
i += 2
while (i < len && body[i] !== '\n') i++
continue
}
if (c === '/' && next === '*') {
i += 2
while (i < len && !(body[i] === '*' && body[i + 1] === '/')) i++
i += 2
continue
}
if (c === '"' || c === "'" || c === '`') {
i++
while (i < len) {
if (body[i] === '\\') { i += 2; continue }
if (body[i] === c) { i++; break }
i++
}
continue
}
if (c === '{') {
if (depth === 0 && pendingKey) { collecting = pendingKey; innerStart = i + 1 }
depth++
i++
continue
}
if (c === '}') {
depth--
if (depth === 0 && collecting) {
entries[collecting] = body.slice(innerStart, i)
collecting = ''
pendingKey = ''
}
i++
continue
}
if (depth === 0 && /[A-Za-z_$]/.test(c)) {
let j = i
while (j < len && /[A-Za-z0-9_$]/.test(body[j])) j++
pendingKey = body.slice(i, j)
i = j
continue
}
i++
}
return entries
}
// Given full text and the index of an opening brace, return the substring up to
// and including the matching closing brace (string/comment aware).
const knxAiSliceBalanced = (text, openIndex) => {
const len = text.length
let i = openIndex
let depth = 0
while (i < len) {
const c = text[i]
const next = text[i + 1]
if (c === '/' && next === '/') {
i += 2
while (i < len && text[i] !== '\n') i++
continue
}
if (c === '/' && next === '*') {
i += 2
while (i < len && !(text[i] === '*' && text[i + 1] === '/')) i++
i += 2
continue
}
if (c === '"' || c === "'" || c === '`') {
i++
while (i < len) {
if (text[i] === '\\') { i += 2; continue }
if (text[i] === c) { i++; break }
i++
}
continue
}
if (c === '{') depth++
else if (c === '}') {
depth--
if (depth === 0) return text.slice(openIndex, i + 1)
}
i++
}
return text.slice(openIndex)
}
const knxAiMatchAfter = (text, regex) => {
const m = regex.exec(text)
return m ? m[1] : ''
}
// Parse one editor `defaults: { ... }` block into a field map.
// Each field becomes { configType, isConfig } where configType is set when the
// field references a config node (e.g. server: { type: 'knxUltimate-config' }).
const knxAiParseDefaultsFields = (defaultsBody) => {
const fields = {}
const entries = knxAiScanObjectEntries(defaultsBody)
Object.keys(entries).forEach((key) => {
const inner = entries[key] || ''
const configType = knxAiMatchAfter(inner, /\btype\s*:\s*['"]([^'"]+)['"]/)
fields[key] = configType ? { configType, isConfig: true } : {}
})
return fields
}
let knxAiPackageNodeCatalogCache = null
// Read every registerType(...) declaration in this package's editor .html files
// and return a catalog: [{ type, paletteLabel, category, inputs, outputs, fields }].
const buildKnxAiPackageNodeCatalog = () => {
if (knxAiPackageNodeCatalogCache) return knxAiPackageNodeCatalogCache
const catalog = []
const seen = new Set()
let nodeMap = {}
try {
const pkg = require(path.join(__dirname, '..', 'package.json'))
nodeMap = (pkg['node-red'] && pkg['node-red'].nodes) || {}
} catch (error) {
nodeMap = {}
}
Object.keys(nodeMap).forEach((mapKey) => {
try {
const jsRel = String(nodeMap[mapKey] || '')
const base = path.basename(jsRel).replace(/\.js$/i, '')
const htmlPath = path.join(__dirname, `${base}.html`)
if (!fs.existsSync(htmlPath)) return
const html = fs.readFileSync(htmlPath, 'utf8')
const re = /registerType\(\s*['"]([^'"]+)['"]\s*,\s*\{/g
let m
while ((m = re.exec(html))) {
const type = m[1]
if (seen.has(type)) continue
seen.add(type)
const objOpen = html.indexOf('{', m.index + m[0].length - 1)
if (objOpen < 0) continue
const objText = knxAiSliceBalanced(html, objOpen)
const category = knxAiMatchAfter(objText, /\bcategory\s*:\s*['"]([^'"]+)['"]/)
const paletteLabel = knxAiMatchAfter(objText, /\bpaletteLabel\s*:\s*['"]([^'"]+)['"]/)
const inputsRaw = knxAiMatchAfter(objText, /\binputs\s*:\s*(\d+)/)
const outputsRaw = knxAiMatchAfter(objText, /\boutputs\s*:\s*(\d+)/)
let fields = {}
const defIdx = objText.search(/\bdefaults\s*:\s*\{/)
if (defIdx >= 0) {
const braceIdx = objText.indexOf('{', defIdx)
const defaultsBlock = knxAiSliceBalanced(objText, braceIdx)
fields = knxAiParseDefaultsFields(defaultsBlock.slice(1, -1))
}
catalog.push({
type,
paletteLabel: paletteLabel || type,
category: category || '',
inputs: inputsRaw === '' ? 1 : Number(inputsRaw),
outputs: outputsRaw === '' ? 1 : Number(outputsRaw),
isConfig: category === 'config',
fields
})
}
} catch (error) {
// skip nodes we cannot parse
}
})
knxAiPackageNodeCatalogCache = catalog
return catalog
}
// Combined catalog (package + core), the set of config-node types, and a
// per-type map of which fields are config references.
const buildKnxAiFlowCatalog = () => {
const packageNodes = buildKnxAiPackageNodeCatalog()
const all = packageNodes.concat(KNX_AI_FLOW_CORE_NODES)
const configTypes = new Set()
const configFieldsByType = {}
const allowedTypes = new Set()
all.forEach((node) => {
allowedTypes.add(node.type)
if (node.isConfig) configTypes.add(node.type)
const refs = []
Object.keys(node.fields || {}).forEach((field) => {
const meta = node.fields[field]
if (meta && meta.isConfig && meta.configType) {
refs.push({ field, configType: meta.configType })
configTypes.add(meta.configType)
}
})
if (refs.length) configFieldsByType[node.type] = refs
})
return { nodes: all, packageNodes, configTypes, configFieldsByType, allowedTypes }
}
// Render the catalog as a compact text block for the LLM system/user prompt.
const renderKnxAiCatalogForPrompt = (catalog) => {
const lines = []
catalog.nodes
.filter(node => node.type !== 'tab')
.forEach((node) => {
const fieldNames = Object.keys(node.fields || {}).map((field) => {
const meta = node.fields[field]
return (meta && meta.isConfig && meta.configType) ? `${field}[ref:${meta.configType}]` : field
})
const io = `${node.inputs}in/${node.outputs}out`
const fieldsText = fieldNames.length ? ` | fields: ${fieldNames.join(', ')}` : ''
lines.push(`- ${node.type} — ${node.paletteLabel} (${io})${fieldsText}`)
})
return lines.join('\n')
}
// Try hard to extract a JSON flow (array of node objects) from an LLM reply.
const parseKnxAiFlowFromLlm = (content) => {
const raw = String(content || '').trim()
if (!raw) return { nodes: [], notes: '', error: 'Empty model response' }
let text = raw
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fence) text = fence[1].trim()
const tryParse = (candidate) => {
try { return JSON.parse(candidate) } catch (error) { return undefined }
}
const fromObject = (obj) => {
if (Array.isArray(obj)) return { nodes: obj, notes: '' }
if (obj && typeof obj === 'object') {
const nodes = Array.isArray(obj.flow) ? obj.flow : (Array.isArray(obj.nodes) ? obj.nodes : null)
if (nodes) return { nodes, notes: String(obj.notes || obj.comment || '') }
}
return null
}
let parsed = tryParse(text)
if (parsed === undefined) {
const firstArr = text.indexOf('[')
const lastArr = text.lastIndexOf(']')
const firstObj = text.indexOf('{')
const lastObj = text.lastIndexOf('}')
if (firstArr >= 0 && lastArr > firstArr) parsed = tryParse(text.slice(firstArr, lastArr + 1))
if (parsed === undefined && firstObj >= 0 && lastObj > firstObj) parsed = tryParse(text.slice(firstObj, lastObj + 1))
}
if (parsed === undefined) return { nodes: [], notes: '', error: 'Could not parse JSON from model response' }
const shaped = fromObject(parsed)
if (!shaped) return { nodes: [], notes: '', error: 'Model response did not contain a flow array' }
return { nodes: shaped.nodes, notes: shaped.notes, error: '' }
}
// Validate / normalize the generated nodes into an importable flow:
// - drops invalid + tab nodes, regenerates unique ids, rewires references
// - puts every wire-able node on a fresh flow tab
// - points config references at real existing config nodes when possible
const normalizeKnxAiGeneratedFlow = ({ rawNodes, catalog, knxServerId, existingConfigByType, genId }) => {
const warnings = []
const allowedTypes = catalog.allowedTypes
const configTypes = catalog.configTypes
const configFieldsByType = catalog.configFieldsByType
const input = Array.isArray(rawNodes) ? rawNodes : []
// Keep only objects with a usable type; skip tab nodes (we add our own) and
// config nodes (we reference existing ones instead of duplicating them).
const kept = []
input.forEach((node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) return
const type = String(node.type || '').trim()
if (!type || type === 'tab') return
if (configTypes.has(type)) {
warnings.push(`Skipped a generated config node of type "${type}"; existing config nodes are reused instead.`)
return
}
if (!allowedTypes.has(type)) {
warnings.push(`Node type "${type}" is not part of the allowed catalog and may not import cleanly.`)
}
kept.push(node)
})
// Map old ids -> fresh ids for every kept node.
const idRemap = new Map()
kept.forEach((node) => {
const oldId = String(node.id || '').trim()
const newId = genId()
if (oldId) idRemap.set(oldId, newId)
node.id = newId
})
const tabId = genId()
let x = 140
let y = 80
const out = kept.map((node) => {
const type = String(node.type).trim()
node.z = tabId
if (!Number.isFinite(Number(node.x))) { node.x = x }
if (!Number.isFinite(Number(node.y))) { node.y = y; y += 70; if (y > 80 + 70 * 6) { y = 80; x += 220 } }
// Remap wires (arrays of arrays of node ids).
if (Array.isArray(node.wires)) {
node.wires = node.wires.map(port => (Array.isArray(port)
? port.map(id => idRemap.get(String(id)) || String(id))
: []))
} else {
node.wires = type === 'link out' || type === 'debug' || type === 'comment' ? [] : [[]]
}
// Resolve config-node references.
const refs = configFieldsByType[type] || []
refs.forEach(({ field, configType }) => {
const current = String(node[field] || '').trim()
if (current && idRemap.has(current)) {
node[field] = idRemap.get(current)
return
}
const existing = existingConfigByType.get(configType) || []
if (configType === 'knxUltimate-config' && knxServerId) {
if (!current || !existing.some(c => c.id === current)) node[field] = knxServerId
return
}
if (!current || !existing.some(c => c.id === current)) {
if (existing.length === 1) node[field] = existing[0].id
else if (existing.length > 1) warnings.push(`Node "${type}" needs a ${configType} config: set it manually after import (several available).`)
else { node[field] = ''; warnings.push(`Node "${type}" needs a ${configType} config node, but none exists yet. Configure it after import.`) }
}
})
return node
})
const tabNode = { id: tabId, type: 'tab', label: 'KNX AI generated flow', disabled: false, info: '' }
return { nodes: [tabNode].concat(out), warnings, tabId }
}
const sendKnxAiVueIndex = (req, res) => {
const entryPath = path.join(knxAiVueDistDir, 'index.html')
fs.readFile(entryPath, 'utf8', (error, html) => {
if (error || typeof html !== 'string') {
res.status(503).type('text/plain').send('KNX AI Vue build not found. Run "npm run knx-ai:build" in the module root.')
return
}
const rawToken = getRequestAccessToken(req)
if (!rawToken) {
res.type('text/html').send(html)
return
}
const encodedToken = encodeURIComponent(rawToken)
const htmlWithToken = html
.replace('./assets/app.js', `./assets/app.js?access_token=${encodedToken}`)
.replace('./assets/app.css', `./assets/app.css?access_token=${encodedToken}`)
res.type('text/html').send(htmlWithToken)
})
}
const sendStaticFileSafe = ({ rootDir, relativePath, res }) => {
const rootPath = path.resolve(rootDir)
const requestedPath = String(relativePath || '').replace(/^\/+/, '')
const fullPath = path.resolve(rootPath, requestedPath)
if (!fullPath.startsWith(rootPath + path.sep) && fullPath !== rootPath) {
res.status(403).type('text/plain').send('Forbidden')
return
}
fs.stat(fullPath, (statError, stats) => {
if (statError || !stats || !stats.isFile()) {
res.status(404).type('text/plain').send('File not found')
return
}
res.sendFile(fullPath, (sendError) => {
if (!sendError || res.headersSent) return
res.status(sendError.statusCode || 500).type('text/plain').send(sendError.message || String(sendError))
})
})
}
const GOOGLE_TRANSLATE_MAX_CHARS = 200
const stripId3v2 = (buffer) => {
if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer
if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer
const size =
((buffer[6] & 0x7f) << 21) |
((buffer[7] & 0x7f) << 14) |
((buffer[8] & 0x7f) << 7) |
(buffer[9] & 0x7f)
const tagEnd = 10 + size
if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer
return buffer.subarray(tagEnd)
}
const splitGoogleTranslateText = (text, maxLen = GOOGLE_TRANSLATE_MAX_CHARS) => {
const chunks = []
let remaining = String(text || '').trim()
if (!remaining) return chunks
const breakChars = ['\n', '.', '!', '?', ';', ':', ',', ' ']
while (remaining.length > maxLen) {
const window = remaining.slice(0, maxLen + 1)
let breakAt = -1
for (const ch of breakChars) {
const idx = window.lastIndexOf(ch)
if (idx > breakAt) breakAt = idx
}
if (breakAt <= 0) breakAt = maxLen
const cutAt = breakAt === maxLen ? maxLen : breakAt + 1
const chunk = remaining.slice(0, cutAt).trim()
if (chunk) chunks.push(chunk)
remaining = remaining.slice(cutAt).trimStart()
}
if (remaining) chunks.push(remaining)
return chunks
}
const synthesizeGoogleTranslateSpeech = async ({ text, voice = 'it', slow = false } = {}) => {
if (!googleTranslateTTS || typeof googleTranslateTTS.synthesize !== 'function') {
throw new Error('Google Translate TTS is not available')
}
const resolvedVoice = typeof voice === 'string' && voice.includes('-') ? voice.split('-')[0] : String(voice || 'it')
const textChunks = splitGoogleTranslateText(text, GOOGLE_TRANSLATE_MAX_CHARS)
if (!textChunks.length) return Buffer.from([])
if (textChunks.length === 1) {
return await googleTranslateTTS.synthesize({
text: textChunks[0],
voice: resolvedVoice,
slow: slow === true
})
}
const buffers = []
for (let i = 0; i < textChunks.length; i += 1) {
// Google Translate TTS accepts only short chunks; concatenate the resulting mp3 frames.
// eslint-disable-next-line no-await-in-loop
const chunkBuffer = await googleTranslateTTS.synthesize({
text: textChunks[i],
voice: resolvedVoice,
slow: slow === true
})
buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer))
}
return Buffer.concat(buffers)
}
const sanitizeApiKey = (value) => {
if (value === undefined || value === null) return ''
let key = String(value).trim()
if (key === '') return ''
// Node-RED password placeholder when credential is already set
if (key === '__PWRD__') return ''
// Common copy/paste mistakes
key = key.replace(/^authorization:\s*/i, '')
key = key.replace(/^bearer\s+/i, '')
key = key.replace(/^"(.+)"$/, '$1').replace(/^'(.+)'$/, '$1')
// If user pasted a full header line, extract the token-like part
const match = key.match(/(sk-[A-Za-z0-9_-]{10,})/)
if (match) return match[1]
return key
}
const safeStringify = (value) => {
try {
if (value === undefined) return ''
if (typeof value === 'string') return value
return JSON.stringify(value)
} catch (error) {
return String(value)
}
}
const truncatePromptText = (value, maxChars = 10000) => {
const text = String(value || '')
const limit = Math.max(256, Number(maxChars) || 0)
if (text.length <= limit) return text
const marker = '\n...[truncated]'
const keep = Math.max(0, limit - marker.length)
return text.slice(0, keep) + marker
}
const compactObjectForPrompt = (value, { preferredKeys = [], maxEntries = 40, formatValue } = {}) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const source = value
const out = {}
const preferred = Array.isArray(preferredKeys) ? preferredKeys.map(key => String(key || '').trim()).filter(Boolean) : []
const preferredSet = new Set(preferred)
const keys = [
...preferred,
...Object.keys(source).filter(key => !preferredSet.has(key))
]
const limit = Math.max(1, Number(maxEntries) || 1)
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue
const raw = source[key]
const normalized = typeof formatValue === 'function' ? formatValue(raw, key) : raw
out[key] = normalized
if (Object.keys(out).length >= limit) break
}
return out
}
const takeLastItemsByCharBudget = (items, maxChars = 7000) => {
const source = Array.isArray(items) ? items : []
const limit = Math.max(200, Number(maxChars) || 0)
const selected = []
let total = 0
for (let i = source.length - 1; i >= 0; i -= 1) {
const item = String(source[i] || '')
if (!item) continue
const next = item.length + (selected.length > 0 ? 1 : 0)
if (selected.length > 0 && (total + next) > limit) break
selected.push(item)
total += next
}
return selected.reverse()
}
const buildLlmSummarySnapshot = (summary) => {
const s = summary && typeof summary === 'object' ? summary : {}
const topGAs = Array.isArray(s.topGAs) ? s.topGAs.slice(0, 30) : []
const topGaKeys = topGAs
.map(item => String(item && item.ga ? item.ga : '').trim())
.filter(Boolean)
const graph = s.graph && typeof s.graph === 'object'
? {
windowSec: Number(s.graph.windowSec || 0),
edges: (Array.isArray(s.graph.edges) ? s.graph.edges : []).slice(0, 60),
hotEdgesDelta: (Array.isArray(s.graph.hotEdgesDelta) ? s.graph.hotEdgesDelta : []).slice(0, 40),
anomalyLifecycle: (Array.isArray(s.graph.anomalyLifecycle) ? s.graph.anomalyLifecycle : []).slice(0, 30)
}
: {}
const flowMapTopology = s.flowMapTopology && typeof s.flowMapTopology === 'object'
? {
mode: String(s.flowMapTopology.mode || '').trim(),
windowSec: Number(s.flowMapTopology.windowSec || 0),
nodes: (Array.isArray(s.flowMapTopology.nodes) ? s.flowMapTopology.nodes : []).slice(0, 80).map((node) => ({
id: String(node && node.id ? node.id : '').trim(),
displayId: String(node && node.displayId ? node.displayId : '').trim(),
kind: String(node && node.kind ? node.kind : '').trim(),
subtitle: String(node && node.subtitle ? node.subtitle : '').trim(),
payload: compactPayloadForNodeLabel(node && Object.prototype.hasOwnProperty.call(node, 'payload') ? node.payload : '', 36),
anomalyCount: Number(node && node.anomalyCount ? node.anomalyCount : 0),
lastSeenAtMs: Number(node && node.lastSeenAtMs ? node.lastSeenAtMs : 0)
})),
edges: (Array.isArray(s.flowMapTopology.edges) ? s.flowMapTopology.edges : []).slice(0, 120).map((edge) => ({
from: String(edge && edge.from ? edge.from : '').trim(),
to: String(edge && edge.to ? edge.to : '').trim(),
linkType: String(edge && edge.linkType ? edge.linkType : '').trim(),
event: String(edge && edge.event ? edge.event : '').trim(),
currentWindowCount: Number(edge && edge.currentWindowCount ? edge.currentWindowCount : 0),
totalCount: Number(edge && edge.totalCount ? edge.totalCount : 0),
delta: Number(edge && edge.delta ? edge.delta : 0),
delayMs: Number(edge && edge.delayMs ? edge.delayMs : 0),
lastAt: String(edge && edge.lastAt ? edge.lastAt : '').trim()
}))
}
: undefined
return {
meta: s.meta && typeof s.meta === 'object' ? s.meta : {},
counters: s.counters && typeof s.counters === 'object' ? s.counters : {},
byEvent: s.byEvent && typeof s.byEvent === 'object' ? s.byEvent : {},
topGAs,
topSources: (Array.isArray(s.topSources) ? s.topSources : []).slice(0, 20),
patterns: (Array.isArray(s.patterns) ? s.patterns : []).slice(0, 30),
gaLastSeenAt: compactObjectForPrompt(s.gaLastSeenAt, { preferredKeys: topGaKeys, maxEntries: 60 }),
gaLastPayload: compactObjectForPrompt(s.gaLastPayload, {
preferredKeys: topGaKeys,
maxEntries: 60,
formatValue: value => compactPayloadForNodeLabel(value, 42)
}),
flowKnownCount: Number(s.flowKnownCount || 0),
busConnection: s.busConnection && typeof s.busConnection === 'object' ? s.busConnection : {},
anomalyLifecycle: (Array.isArray(s.anomalyLifecycle) ? s.anomalyLifecycle : []).slice(-40),
graph,
flowMapTopology
}
}
const extractJsonFragmentFromText = (value) => {
const text = String(value || '').trim()
if (!text) throw new Error('Empty AI response')
const normalizeCandidate = (input) => String(input || '')
.replace(/^\uFEFF/, '')
.replace(/^\s*json\s*\n/i, '')
.trim()
const tryParse = (input) => {
const source = normalizeCandidate(input)
if (!source) return null
try {
return JSON.parse(source)
} catch (error) { }
// Fallback: tolerate comments and trailing commas that some models emit.
const relaxed = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '')
.replace(/,\s*([}\]])/g, '$1')
.trim()
if (!relaxed || relaxed === source) return null
try {
return JSON.parse(relaxed)
} catch (error) {
return null
}
}
const extractBalancedJsonSlices = (input, maxSlices = 24) => {
const source = String(input || '')
const out = []
for (let i = 0; i < source.length; i += 1) {
const ch = source[i]
if (ch !== '{' && ch !== '[') continue
const stack = [ch === '{' ? '}' : ']']
let inString = false
let escaped = false
for (let j = i + 1; j < source.length; j += 1) {
const current = source[j]
if (inString) {
if (escaped) {
escaped = false
continue
}
if (current === '\\') {
escaped = true
continue
}
if (current === '"') inString = false
continue
}
if (current === '"') {
inString = true
continue
}
if (current === '{') {
stack.push('}')
continue
}
if (current === '[') {
stack.push(']')
continue
}
if ((current === '}' || current === ']') && stack.length) {
if (current !== stack[stack.length - 1]) break
stack.pop()
if (!stack.length) {
const slice = normalizeCandidate(source.slice(i, j + 1))
if (slice) out.push(slice)
i = j
break
}
}
}
if (out.length >= maxSlices) break
}
return out
}
const candidates = []
const seen = new Set()
const pushCandidate = (input) => {
const normalized = normalizeCandidate(input)
if (!normalized || seen.has(normalized)) return
seen.add(normalized)
candidates.push(normalized)
}
pushCandidate(text)
const fencedRe = /```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/g
let fenceMatch
while ((fenceMatch = fencedRe.exec(text)) !== null) {
pushCandidate(fenceMatch[1])
}
for (const candidate of candidates) {
const direct = tryParse(candidate)
if (direct !== null) return direct
const objectStart = candidate.indexOf('{')
const objectEnd = candidate.lastIndexOf('}')
if (objectStart !== -1 && objectEnd !== -1 && objectEnd > objectStart) {
const parsedObject = tryParse(candidate.slice(objectStart, objectEnd + 1))
if (parsedObject !== null) return parsedObject
}
const arrayStart = candidate.indexOf('[')
const arrayEnd = candidate.lastIndexOf(']')
if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
const parsedArray = tryParse(candidate.slice(arrayStart, arrayEnd + 1))
if (parsedArray !== null) return parsedArray
}
const balancedSlices = extractBalancedJsonSlices(candidate)
for (const slice of balancedSlices) {
const parsedSlice = tryParse(slice)
if (parsedSlice !== null) return parsedSlice
}
}
const preview = text.slice(0, 180).replace(/\s+/g, ' ').trim()
throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`)
}
const normalizeValueForCompare = (value) => {
if (value === undefined) return 'undefined'
if (value === null) return 'null'
if (Buffer.isBuffer(value)) return `buffer:${value.toString('hex')}`
if (typeof value === 'object') return safeStringify(value)
return String(value)
}
const nowMs = () => Date.now()
const roundTo = (value, digits = 2) => {
const n = Number(value)
if (!Number.isFinite(n)) return 0
const f = 10 ** Math.max(0, Number(digits) || 0)
return Math.round(n * f) / f
}
const percentileFromArray = (values, percentile = 0.95) => {
const arr = Array.isArray(values) ? values.filter(v => Number.isFinite(Number(v))).map(v => Number(v)) : []
if (!arr.length) return 0
arr.sort((a, b) => a - b)
const p = Math.max(0, Math.min(1, Number(percentile) || 0))
if (arr.length === 1) return arr[0]
const idx = Math.floor((arr.length - 1) * p)
return arr[idx]
}
const edgeKey = (from, to) => `${from} -> ${to}`
const computeAnomalySeverity = (payload) => {
const p = payload || {}
let ratio = 1
if (p.thresholdPerSec > 0 && p.ratePerSec > 0) ratio = Number(p.ratePerSec) / Number(p.thresholdPerSec)
if (p.thresholdChanges > 0 && p.changesInWindow > 0) ratio = Number(p.changesInWindow) / Number(p.thresholdChanges)
if (!Number.isFinite(ratio) || ratio <= 0) ratio = 1
if (ratio >= 3) return { label: 'critical', score: roundTo(ratio, 2) }
if (ratio >= 2) return { label: 'high', score: roundTo(ratio, 2) }
if (ratio >= 1.25) return { label: 'medium', score: roundTo(ratio, 2) }
return { label: 'low', score: roundTo(ratio, 2) }
}
const SVG_REQUEST_RE = /\b(svg|chart|graph|plot|diagram|bar|pie|line|grafico|grafici|diagramma|istogramma|torta)\b/i
const SVG_PRESENT_RE = /```svg[\s\S]*?```|<svg[\s>][\s\S]*?<\/svg>/i
const FUNCTION_NODE_CODE_REVIEW_RE = /\b(function|function node|nodo function|nodi function)\b/i
const JAVASCRIPT_REVIEW_RE = /\b(js|javascript|java\s*script|code|codice|script|sorgente|source|errore|errori|error|bug|review|reviewa|analizza|analy(?:s|z)e|check|controlla|debug)\b/i
const escapeXml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
const truncateLabel = (value, maxLen = 14) => {
const s = String(value || '')
if (s.length <= maxLen) return s
return s.slice(0, Math.max(1, maxLen - 2)) + '..'
}
const shouldGenerateSvgChart = (question) => SVG_REQUEST_RE.test(String(question || ''))
const shouldIncludeFunctionNodeSourceContext = (question) => {
const q = String(question || '').trim()
if (!q) return false
return FUNCTION_NODE_CODE_REVIEW_RE.test(q) && JAVASCRIPT_REVIEW_RE.test(q)
}
const normalizeCodeBlockText = (value) => String(value || '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.trim()
const stripPayloadDecimals = (value) => {
if (value === undefined || value === null) return value
if (typeof value === 'number') {
if (!Number.isFinite(value)) return value
return Math.trunc(value)
}
if (Array.isArray(value)) return value.map(v => stripPayloadDecimals(v))
if (typeof value === 'object') {
const out = {}
Object.keys(value).forEach((k) => {
out[k] = stripPayloadDecimals(value[k])
})
return out
}
if (typeof value === 'string') {
const s = String(value).trim()
if (s === '') return ''
if (/^[+-]?\d+(?:\.\d+)?$/.test(s)) {
const n = Number(s)
if (Number.isFinite(n)) return String(Math.trunc(n))
}
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
try {
const parsed = JSON.parse(s)
return safeStringify(stripPayloadDecimals(parsed))
} catch (error) {
return s
}
}
return s
}
return value
}
const compactPayloadForNodeLabel = (value, maxLen = 28) => {
const normalizedPayload = stripPayloadDecimals(value)
let s = normalizeValueForCompare(normalizedPayload)
s = String(s || '').replace(/\s+/g, ' ').trim()
if (s.length <= maxLen) return s
return s.slice(0, Math.max(1, maxLen - 2)) + '..'
}
const normalizeAreaText = (value) => String(value || '')
.replace(/\s+/g, ' ')
.replace(/[–—]/g, '-')
.trim()
const slugifyAreaText = (value) => normalizeAreaText(value)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'area'
const pushUniqueValue = (list, value, maxItems = 6) => {
const normalized = normalizeAreaText(value)
if (!normalized) return
if (!Array.isArray(list)) return
if (list.includes(normalized)) return
if (list.length >= maxItems) return
list.push(normalized)
}
const normalizeGaRoleValue = (value, fallback = 'auto') => {
const raw = normalizeAreaText(value).toLowerCase()
if (['auto', 'command', 'status', 'neutral'].includes(raw)) return raw
return fallback
}
const parseEtsHierarchyLabel = (value) => {
const raw = normalizeAreaText(value)
if (!raw) {
return {
raw: '',
deviceLabel: '',
mainGroup: '',
middleGroup: '',
hierarchyPath: ''
}
}
const match = raw.match(/^\(([^()]+)\)\s*(.*)$/)
if (!match) {
return {
raw,
deviceLabel: raw,
mainGroup: '',
middleGroup: '',
hierarchyPath: ''
}
}
const hierarchy = String(match[1] || '')
.split('->')
.map(part => normalizeAreaText(part))
.filter(Boolean)
return {
raw,
deviceLabel: normalizeAreaText(match[2] || raw),
mainGroup: hierarchy[0] || '',
middleGroup: hierarchy[1] || '',
hierarchyPath: hierarchy.join(' / ')
}
}
const AREA_TAG_RULES = [
{ tag: 'lighting', pattern: /\b(light|lights|lighting|luce|luci|lamp|dimmer)\b/i },
{ tag: 'hvac', pattern: /\b(hvac|clima|climate|fan\s?coil|fancoil|heating|cooling|thermo|temp|temperature)\b/i },
{ tag: 'shading', pattern: /\b(blind|blinds|shutter|shutters|jalousie|curtain|curtains|tapparella|tapparelle)\b/i },
{ tag: 'presence', pattern: /\b(presence|occupancy|motion|presence detector|pir|presence sensor|presence)\b/i },
{ tag: 'access', pattern: /\b(door|doors|window|windows|access|lock|badge|porta|porte|finestra|finestre)\b/i },
{ tag: 'energy', pattern: /\b(power|energy|meter|consumption|load|carico|consumo|misura)\b/i }
]
const inferAreaTags = ({ mainGroup, middleGroup, deviceLabel, dpt }) => {
const text = [mainGroup, middleGroup, deviceLabel, dpt].filter(Boolean).join(' ')
const tags = []
AREA_TAG_RULES.forEach((rule) => {
if (rule.pattern.test(text)) tags.push(rule.tag)
})
return tags
}
const buildSuggestedAreasFromCsv = (csv) => {
const rows = Array.isArray(csv) ? csv : []
const areasById = new Map()
let hierarchicalGaCount = 0
let secondaryGroupCount = 0
let mainGroupCount = 0
const ensureArea = ({ id, kind, name, parentName, pathTokens }) => {
const key = String(id || '').trim()
if (!key) return null
if (!areasById.has(key)) {
areasById.set(key, {
id: key,
kind: String(kind || 'area').trim() || 'area',
name: normalizeAreaText(name || ''),
parentName: normalizeAreaText(parentName || ''),
pathTokens: Array.isArray(pathTokens) ? pathTokens.map(token => normalizeAreaText(token)).filter(Boolean) : [],
gaSet: new Set(),
dptSet: new Set(),
tags: new Set(),
sampleGAs: [],
sampleLabels: []
})
if (kind === 'secondary_group') secondaryGroupCount += 1
if (kind === 'main_group') mainGroupCount += 1
}
return areasById.get(key)
}
const registerAreaRow = ({ areaId, kind, name, parentName, pathTokens, row, parsed }) => {
const area = ensureArea({ id: areaId, kind, name, parentName, pathTokens })
if (!area) return
const ga = normalizeAreaText(row && row.ga)
const dpt = normalizeAreaText(row && row.dpt)
if (ga) area.gaSet.add(ga)
if (dpt) area.dptSet.add(dpt)
pushUniqueValue(area.sampleGAs, ga, 6)
pushUniqueValue(area.sampleLabels, parsed && parsed.deviceLabel, 4)
inferAreaTags({
mainGroup: parsed && parsed.mainGroup,
middleGroup: parsed && parsed.middleGroup,
deviceLabel: parsed && parsed.deviceLabel,
dpt
}).forEach(tag => area.tags.add(tag))
}
rows.forEach((row) => {
const ga = normalizeAreaText(row && row.ga)
if (!ga) return
const parsed = parseEtsHierarchyLabel(row && row.devicename)
if (parsed.mainGroup || parsed.middleGroup) hierarchicalGaCount += 1
if (parsed.mainGroup) {
registerAreaRow({
areaId: `main:${slugifyAreaText(parsed.mainGroup)}`,
kind: 'main_group',
name: parsed.mainGroup,
parentName: '',
pathTokens: [parsed.mainGroup],
row,
parsed
})
}
if (parsed.mainGroup && parsed.middleGroup) {
registerAreaRow({
areaId: `secondary:${slugifyAreaText(parsed.mainGroup)}:${slugifyAreaText(parsed.middleGroup)}`,
kind: 'secondary_group',
name: parsed.middleGroup,
parentName: parsed.mainGroup,
pathTokens: [parsed.mainGroup, parsed.middleGroup],
row,
parsed
})
}
})
const suggested = Array.from(areasById.values())
.map((entry) => {
const gaCount = entry.gaSet.size
const dptCount = entry.dptSet.size
const path = entry.pathTokens.join(' / ')
return {
id: entry.id,
kind: entry.kind,
name: entry.name,
baseName: entry.name,
parentId: entry.kind === 'secondary_group' ? `main:${slugifyAreaText(entry.parentName)}` : '',
parentName: entry.parentName,
baseParentName: entry.parentName,
path,
basePath: path,
gaCount,
dptCount,
gaList: Array.from(entry.gaSet.values()).sort(),
dptList: Array.from(entry.dptSet.values()).sort(),
tags: Array.from(entry.tags.values()).sort(),
baseTags: Array.from(entry.tags.values()).sort(),
sampleGAs: entry.sampleGAs.slice(0, 6),
sampleLabels: entry.sampleLabels.slice(0, 4),
description: entry.kind === 'secondary_group'
? `${entry.parentName || 'ETS'} / ${entry.name} (${gaCount} GA)`
: `${entry.name} (${gaCount} GA)`,
priority: entry.kind === 'secondary_group' ? 2 : 1
}
})
.sort((a, b) => {
if (b.priority !== a.priority) return b.priority - a.priority
if (b.gaCount !== a.gaCount) return b.gaCount - a.gaCount
return String(a.path || a.name || '').localeCompare(String(b.path || b.name || ''))
})
return {
source: rows.length ? 'ets_csv' : 'none',
generatedAt: new Date().toISOString(),
totals: {
gaCount: rows.length,
hierarchicalGaCount,
suggestedAreaCount: suggested.length,
secondaryGroupCount,
mainGroupCount
},
suggested
}
}
const buildGaCatalogFromCsv = (csv) => {
const rows = Array.isArray(csv) ? csv : []
const byGa = new Map()
rows.forEach((row) => {
const ga = normalizeAreaText(row && row.ga)
if (!ga || byGa.has(ga)) return
const parsed = parseEtsHierarchyLabel(row && row.devicename)
const dpt = normalizeAreaText(row && row.dpt)
const label = normalizeAreaText(parsed.deviceLabel || row.devicename || ga)
const roleDetails = inferSignalRoleDetails({ label, dpt })
const tags = inferAreaTags({
mainGroup: parsed.mainGroup,
middleGroup: parsed.middleGroup,
deviceLabel: label,
dpt
})
byGa.set(ga, {
ga,
dpt,
label,
etsName: normalizeAreaText(row && row.devicename),
baseRole: roleDetails.role,
baseRoleSource: roleDetails.source,
role: roleDetails.role,
roleSource: roleDetails.source,
roleOverride: 'auto',
mainGroup: parsed.mainGroup || '',
middleGroup: parsed.middleGroup || '',
hierarchyPath: parsed.hierarchyPath || '',
tags,
valueOptions: getDptValueOptions(dpt)
})
})
return Array.from(byGa.values())
.sort((a, b) => {
const left = `${a.hierarchyPath} ${a.label} ${a.ga}`.trim()
const right = `${b.hierarchyPath} ${b.label} ${b.ga}`.trim()
return left.localeCompare(right)
})
}
const applyGaRoleOverridesToCatalog = ({ catalog, roleOverrides }) => {
const rawCatalog = Array.isArray(catalog) ? catalog : []
const overrides = roleOverrides && typeof roleOverrides === 'object' ? roleOverrides : {}
return rawCatalog.map((item) => {
const ga = String(item && item.ga ? item.ga : '').trim()
const overrideRole = normalizeGaRoleValue(overrides[ga], 'auto')
return Object.assign({}, item, {
role: overrideRole === 'auto' ? normalizeGaRoleValue(item && item.baseRole ? item.baseRole : item && item.role ? item.role : 'neutral', 'neutral') : overrideRole,
roleSource: overrideRole === 'auto'
? String(item && item.baseRoleSource ? item.baseRoleSource : item && item.roleSource ? item.roleSource : 'unknown_rule')
: 'user_override',
roleOverride: overrideRole
})
})
}
const isAmbiguousGaRoleSource = (source) => {
const value = normalizeAreaText(source).toLowerCase()
return value === 'dpt_rule' || value === 'unknown_rule'
}
const normalizeGaRoleSuggestionPayload = ({ payload, gaCatalogMap }) => {
const parsed = payload && typeof payload === 'object' ? payload : {}
const rawRoles = Array.isArray(parsed)
? parsed
: Array.isArray(parsed.roles)
? parsed.roles
: Array.isArray(parsed.items)
? parsed.items
: []
const overrides = {}
rawRoles.forEach((entry) => {
const ga = normalizeAreaText(entry && (entry.ga || entry.groupAddress || entry.address))
if (!ga || !gaCatalogMap.has(ga)) return
const role = normalizeGaRoleValue(entry && entry.role, 'auto')
if (role === 'auto') return
overrides[ga] = role
})
return overrides
}
const normalizeLanguageCode = (value, fallback = 'en') => {
const raw = normalizeAreaText(value).toLowerCase()
if (!raw) return fallback
const match = raw.match(/^[a-z]{2,3}/)
return match ? match[0] : fallback
}
const extractLanguageCodeFromHeader = (value, fallback = 'en') => {
const raw = normalizeAreaText(value)
if (!raw) return fallback
const first = raw.split(',')[0] || ''
return normalizeLanguageCode(first, fallback)
}
const languageNameFromCode = (value) => {
const code = normalizeLanguageCode(value, 'en')
const map = {
it: 'Italian',
en: 'English',
de: 'German',
fr: 'French',
es: 'Spanish',
pt: 'Portuguese',
nl: 'Dutch'
}
return map[code] || code
}
const enrichSuggestedAreasWithSummary = ({ baseSnapshot, summary }) => {
const snapshot = baseSnapshot && typeof baseSnapshot === 'object' ? baseSnapshot : buildSuggestedAreasFromCsv([])
const gaLastSeenAt = summary && typeof summary.gaLastSeenAt === 'object' ? summary.gaLastSeenAt : {}
const gaLastPayload = summary && typeof summary.gaLastPayload === 'object' ? summary.gaLastPayload : {}
const analysisWindowSec = Math.max(30, Number(summary && summary.meta && summary.meta.analysisWindowSec) || 0)
const activeCutoffMs = nowMs() - (analysisWindowSec * 1000)
let activeAreaCount = 0
const suggested = (Array.isArray(snapshot.suggested) ? snapshot.suggested : []).map((area) => {
let activeGaCount = 0
let lastSeenAtMs = 0
const recentPayloads = []
; (Array.isArray(area.sampleGAs) ? area.sampleGAs : []).forEach((ga) => {
const ts = new Date(String(gaLastSeenAt[ga] || '')).getTime()
if (Number.isFinite(ts) && ts > 0) {
lastSeenAtMs = Math.max(lastSeenAtMs, ts)
if (ts >= activeCutoffMs) activeGaCount += 1
}
if (gaLastPayload[ga] !== undefined) {
pushUniqueValue(recentPayloads, `${ga}: ${compactPayloadForNodeLabel(gaLastPayload[ga], 22)}`, 4)
}
})
if (activeGaCount > 0) activeAreaCount += 1
return Object.assign({}, area, {
activeGaCount,
activityPct: area.gaCount > 0 ? roundTo((activeGaCount / area.gaCount) * 100, 1) : 0,
lastSeenAt: lastSeenAtMs > 0 ? new Date(lastSeenAtMs).toISOString() : '',
recentPayloads
})
})
return {
source: snapshot.source || 'none',
generatedAt: new Date().toISOString(),
totals: Object.assign({}, snapshot.totals || {}, {
activeAreaCount
}),
suggested
}
}
const buildAreasPromptContext = (areasSnapshot) => {
const suggested = Array.isArray(areasSnapshot && areasSnapshot.suggested) ? areasSnapshot.suggested : []
if (!suggested.length) return ''
const lines = suggested.slice(0, 12).map((area) => {
const tags = Array.isArray(area.tags) && area.tags.length ? ` tags=${area.tags.join(',')}` : ''
const activity = area.gaCount > 0 ? ` active=${Number(area.activeGaCount || 0)}/${Number(area.gaCount || 0)}` : ''
return `- ${area.path || area.name} [${area.kind}]${activity}${tags}`
})
return [
'Suggested installation areas derived from ETS hierarchy:',
lines.join('\n')
].join('\n')
}
const ensureDirectorySync = (dirPath) => {
const target = String(dirPath || '').trim()
if (!target) return false
try {
fs.mkdirSync(target, { recursive: true })
return true
} catch (error) {
return false
}
}
const readJsonFileSafe = (filePath, fallbackValue) => {
try {
if (!fs.existsSync(filePath)) return fallbackValue
const raw = fs.readFileSync(filePath, 'utf8')
if (!raw || String(raw).trim() === '') return fallbackValue
return JSON.parse(raw)
} catch (error) {
return fallbackValue
}
}
const formatArchiveDayKey = (ts) => {
try {
return new Date(ts).toISOString().slice(0, 10)
} catch (error) {
return new Date().toISOString().slice(0, 10)
}
}
const collectArchiveDayKeysBetween = ({ fromTs, toTs }) => {
const out = []
const start = Number(fromTs || 0)
const end = Number(toTs || 0)
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out
const cursor = new Date(start)
cursor.setUTCHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setUTCHours(0, 0, 0, 0)
while (cursor.getTime() <= endDay.getTime()) {
out.push(cursor.toISOString().slice(0, 10))
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return out
}
const startOfLocalDayMs = (ts) => {
const d = new Date(ts)
d.setHours(0, 0, 0, 0)
return d.getTime()
}
const endOfLocalDayMs = (ts) => {
const d = new Date(ts)
d.setHours(23, 59, 59, 999)
return d.getTime()
}
const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
const text = String(question || '').trim().toLowerCase()
if (!text) return null
const exactDates = Array.from(text.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)).map(match => match[1])
if (exactDates.length >= 2) {
const start = new Date(`${exactDates[0]}T00:00:00`).getTime()
const end = new Date(`${exactDates[1]}T23:59:59.999`).getTime()
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
return { fromTs: start, toTs: end, label: `${exactDates[0]}..${exactDates[1]}`, explicit: true }
}
}
if (exactDates.length === 1) {
const start = new Date(`${exactDates[0]}T00:00:00`).getTime()
const end = new Date(`${exactDates[0]}T23:59:59.999`).getTime()
if (Number.isFinite(start) && Number.isFinite(end)) {
return { fromTs: start, toTs: end, label: exactDates[0], explicit: true }
}
}
const dayStart = startOfLocalDayMs(nowTs)
const dayEnd = endOfLocalDayMs(nowTs)
const yesterdayStart = dayStart - (24 * 60 * 60 * 1000)
const yesterdayEnd = dayStart - 1
if (/\b(stamattina|this morning)\b/.test(text)) {
return { fromTs: dayStart, toTs: Math.min(dayEnd, dayStart + (12 * 60 * 60 * 1000) - 1), label: 'this morning', explicit: true }
}
if (/\b(oggi pomeriggio|this afternoon)\b/.test(text)) {
return { fromTs: dayStart + (12 * 60 * 60 * 1000), toTs: Math.min(dayEnd, dayStart + (18 * 60 * 60 * 1000) - 1), label: 'this afternoon', explicit: true }
}
if (/\b(stasera|this evening|tonight)\b/.test(text)) {
return { fromTs: dayStart + (18 * 60 * 60 * 1000), toTs: dayEnd, label: 'this evening', explicit: true }
}
if (/\b(oggi|today)\b/.test(text)) {
return { fromTs: dayStart, toTs: dayEnd, label: 'today', explicit: true }
}
if (/\b(ieri|yesterday)\b/.test(text)) {
return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
}
const lastDaysMatch = text.match(/\b(?:last|ultimi)\s+(\d{1,3})\s+(?:day|days|giorno|giorni)\b/)
if (lastDaysMatch) {
const days = Math.max(1, Number(lastDaysMatch[1] || 1))
return {
fromTs: nowTs - (days * 24 * 60 * 60 * 1000),
toTs: nowTs,
label: `last ${days} days`,
explicit: true
}
}
if (/\b(this week|questa settimana)\b/.tes