UNPKG

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,336 lines (1,235 loc) 328 kB
// 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') 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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;') 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/.test(text)) { const d = new Date(nowTs) const dow = d.getDay() const offset = dow === 0 ? 6 : dow - 1 d.setHours(0, 0, 0, 0) d.setDate(d.getDate() - offset) return { fromTs: d.getTime(), toTs: nowTs, label: 'this week', explicit: true } } return null } const normalizeAreaOverridePayload = (payload) => { const p = payload && typeof payload === 'object' ? payload : {} const normalized = {} if (Object.prototype.hasOwnProperty.call(p, 'name')) normalized.name = normalizeAreaText(p.name) if (Object.prototype.hasOwnProperty.call(p, 'description')) normalized.description = normalizeAreaText(p.description) if (Object.prototype.hasOwnProperty.call(p, 'deleted')) normalized.deleted = p.deleted === true if (Object.prototype.hasOwnProperty.call(p, 'tags')) { normalized.tags = Array.isArray(p.tags) ? Array.from(new Set(p.tags.map(tag => slugifyAreaText(tag)).filter(Boolean))).slice(0, 12) : [] } if (Object.prototype.hasOwnProperty.call(p, 'gaList')) { normalized.gaList = Array.isArray(p.gaList) ? Array.from(new Set(p.gaList.map(ga => normalizeAreaText(ga)).filter(Boolean))).slice(0, 5000) : [] } return normalized } const normalizeCustomAreaId = (value, fallback = '') => { const raw = normalizeAreaText(value || fallback) const slug = slugifyAreaText(raw) return slug ? `custom:${slug}` : '' } const applyAreaOverridesToSnapshot = ({ snapshot, overrides, gaCatalog }) => { const baseSnapshot = snapshot && typeof snapshot === 'object' ? snapshot : buildSuggestedAreasFromCsv([]) const rawOverrides = overrides && typeof overrides === 'object' ? overrides : {} const gaCatalogMap = new Map((Array.isArray(gaCatalog) ? gaCatalog : []).map(item => [String(item && item.ga ? item.ga : '').trim(), item])) const baseAreas = Array.isArray(baseSnapshot.suggested) ? baseSnapshot.suggested : [] const byId = new Map() baseAreas.forEach((area) => { const override = rawOverrides[area.id] && typeof rawOverrides[area.id] === 'object' ? normalizeAreaOverridePayload(rawOverrides[area.id]) : {} if (override.deleted === true) return byId.set(area.id, Object.assign({}, area, { customName: Object.prototype.hasOwnProperty.call(override, 'name') ? override.name : '', customDescription: Object.prototype.hasOwnProperty.call(override, 'description') ? override.description : '', customTags: Object.prototype.hasOwnProperty.call(override, 'tags') ? override.tags : null, customGaList: Object.prototype.hasOwnProperty.call(override, 'gaList') ? override.gaList : null, hasOverride: Object.keys(override).length > 0 })) }) Object.keys(rawOverrides).forEach((overrideId) => { if (byId.has(overrideId)) return const override = normalizeAreaOverridePayload(rawOverrides[overrideId]) if (override.deleted === true) return const customGaList = Array.isArray(override.gaList) ? override.gaList.filter(ga => gaCatalogMap.has(ga)) : [] const inferredTags = new Set(Array.isArray(override.tags) ? override.tags : []) const sampleLabels = [] const dptSet = new Set() customGaList.forEach((ga) => { const item = gaCatalogMap.get(ga) if (!item) return if (item.dpt) dptSet.add(item.dpt) pushUniqueValue(sampleLabels, item.label, 4) ; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag)) }) const customName = normalizeAreaText(override.name || overrideId.replace(/^custom:/, '')) const isLlmGenerated = String(overrideId || '').startsWith('llm:') byId.set(overrideId, { id: overrideId, kind: isLlmGenerated ? 'custom_llm' : 'custom_manual', name: customName, baseName: customName, parentId: '', parentName: '', baseParentName: '', path: customName, basePath: customName, gaCount: customGaList.length, dptCount: dptSet.size, gaList: customGaList, dptList: Array.from(dptSet.values()).sort(), tags: Array.from(inferredTags.values()).sort(), baseTags: Array.from(inferredTags.values()).sort(), sampleGAs: customGaList.slice(0, 6), sampleLabels, description: normalizeAreaText(override.description || `${customName} (${customGaList.length} GA)`), priority: 3, customName, customDescription: normalizeAreaText(override.description || ''), customTags: Array.isArray(override.tags) ? override.tags.slice(0, 12) : null, customGaList, hasOverride: true }) }) const resolveAreaName = (area) => normalizeAreaText((area && area.customName) || (area && area.baseName) || (area && area.name)) byId.forEach((area) => { const parentArea = area.parentId ? byId.get(area.parentId) : null const resolvedName = resolveAreaName(area) const resolvedParentName = parentArea ? resolveAreaName(parentArea) : normalizeAreaText(area.baseParentName || area.parentName) const resolvedPath = parentArea ? [normalizeAreaText(parentArea.path || parentArea.name), resolvedName].filter(Boolean).join(' / ') : resolvedName let tags = Array.isArray(area.customTags) ? area.customTags.slice(0, 12) : (Array.isArray(area.baseTags) ? area.baseTags.slice(0, 12) : []) let gaList = Array.isArray(area.gaList) ? area.gaList.slice() : [] let dptList = Array.isArray(area.dptList) ? area.dptList.slice() : [] let sampleGAs = Array.isArray(area.sampleGAs) ? area.sampleGAs.slice(0, 6) : [] let sampleLabels = Array.isArray(area.sampleLabels) ? area.sampleLabels.slice(0, 4) : [] if (Array.isArray(area.gaList) && Array.isArray(area.customGaList)) { const filtered = area.customGaList .filter(ga => gaCatalogMap.has(ga)) gaList = filtered const nextDptSet = new Set() const nextLabelSet = [] const inferredTags = new Set() filtered.forEach((ga) => { const item = gaCatalogMap.get(ga) if (!item) return if (item.dpt) nextDptSet.add(item.dpt) pushUniqueValue(nextLabelSet, item.label, 4) ; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag)) }) dptList = Array.from(nextDptSet.values()).sort() sampleGAs = filtered.slice(0, 6) sampleLabels = nextLabelSet if (!Array.isArray(area.customTags)) tags = Array.from(inferredTags.values()).sort() } const gaCount = gaList.length const dptCount = dptList.length const description = area.customDescription !== '' ? area.customDescription : area.kind === 'secondary_group' ? `${resolvedParentName || 'ETS'} / ${resolvedName} (${gaCount} GA)` : `${resolvedName} (${gaCount} GA)` Object.assign(area, { name: resolvedName, parentName: resolvedParentName, path: resolvedPath, tags, description, gaList, dptList, gaCount, dptCount, sampleGAs, sampleLabels }) }) return Object.assign({}, baseSnapshot, { generatedAt: new Date().toISOString(), suggested: Array.from(byId.values()) }) } const DEFAULT_AREA_PROFILES = [ { id: 'area_diagnostic', builtIn: true, name: 'Control Area', description: 'General read-only diagnostic of the selected area based on ETS structure and current KNX activity.', minActivityPct: 20, maxSilentPct: 60, maxAnomalies: 2, targetTags: [] }, { id: 'lighting_area', builtIn: true, name: 'Lighting Area', description: 'Focus on lighting-oriented areas and highlight low activity or repeated anomalies.', minActivityPct: 15, maxSilentPct: 70, maxAnomalies: 1, targetTags: ['lighting'] }, { id: 'hvac_area', builtIn: true, name: 'HVAC Area', description: 'Focus on HVAC-oriented areas and check whether the related addresses are alive.', minActivityPct: 10, maxSilentPct: 80, maxAnomalies: 1, targetTags: ['hvac'] } ] const clampNumber = (value, { min = 0, max = 100, fallback = 0 } = {}) => { const n = Number(value) if (!Number.isFinite(n)) return fallback if (n < min) return min if (n > max) return max return n } const normalizeProfileText = (value, fallback = '') => normalizeAreaText(value || fallback) const normalizeAreaProfilePayload = (payload, fallbackId = '') => { const p = payload && typeof payload === 'object' ? payload : {} const name = normalizeProfileText(p.name, 'Custom Area Profile') const baseId = normalizeAreaText(p.id || fallbackId || name) return { id: slugifyAreaText(baseId), builtIn: false, name, description: normalizeProfileText(p.description), minActivityPct: clampNumber(p.minActivityPct, { min: 0, max: 100, fallback: 20 }), maxSilentPct: clampNumber(p.maxSilentPct, { min: 0, max: 100, fallback: 60 }), maxAnomalies: clampNumber(p.maxAnomalies, { min: 0, max: 999, fallback: 2 }), targetTags: Array.isArray(p.targetTags) ? Array.from(new Set(p.targetTags.map(tag => slugifyAreaText(tag)).filter(Boolean))).slice(0, 12) : [] } } const mergeAreaProfiles = ({ customProfiles }) => { const out = new Map() DEFAULT_AREA_PROFILES.forEach((profile) => { out.set(profile.id, Object.assign({}, profile)) }) ; (Array.isArray(customProfiles) ? customProfiles : []).forEach((profile, index) => { const normalized = normalizeAreaProfilePayload(profile, `custom-${index + 1}`) if (!normalized.id) return out.set(normalized.id, normalized) }) return Array.from(out.values()) } const severityRank = (status) => { const value = String(status || '').toLowerCase() if (value === 'fail') return 3 if (value === 'warn') return 2 if (value === 'pass') return 1 return 0 } const buildAreaProfileReport = ({ area, profile, summary, anomalies, generatedAt }) => { const safeArea = area && typeof area === 'object' ? area : {} const safeProfile = profile && typeof profile === 'object' ? profile : {} const safeSummary = summary && typeof summary === 'object' ? summary : {} const gaList = Array.isArray(safeArea.gaList) ? safeArea.gaList.slice() : [] const gaSet = new Set(gaList.map(ga => String(ga || '').trim()).filter(Boolean)) const gaLastSeenAt = safeSummary && typeof safeSummary.gaLastSeenAt === 'object' ? safeSummary.gaLastSeenAt : {} const gaLastPayload = safeSummary && typeof safeSummary.gaLastPayload === 'object' ? safeSummary.gaLastPayload : {} const analysisWindowSec = Math.max(30, Number(safeSummary && safeSummary.meta && safeSummary.meta.analysisWindowSec) || 0) const activeCutoffMs = nowMs() - (analysisWindowSec * 1000) const activeGAs = [] const silentGAs = [] gaList.forEach((ga) => { const ts = new Date(String(gaLastSeenAt[ga] || '')).getTime() if (Number.isFinite(ts) && ts > 0 && ts >= activeCutoffMs) { activeGAs.push(ga) } else { silentGAs.push(ga) } }) const relevantAnomalies = (Array.isArray(anomalies) ? anomalies : []) .filter((entry) => { const ga = String(entry && entry.payload && entry.payload.ga ? entry.payload.ga : '').trim() return ga && gaSet.has(ga) }) .slice(-50) .reverse() const totalGAs = gaList.length const activeGaCount = activeGAs.length const silentGaCount = silentGAs.length const activityPct = totalGAs > 0 ? roundTo((activeGaCount / totalGAs) * 100, 1) : 0 const silentPct = totalGAs > 0 ? roundTo((silentGaCount / totalGAs) * 100, 1) : 0 const tagMismatch = Array.isArray(safeProfile.targetTags) && safeProfile.targetTags.length > 0 ? !safeProfile.targetTags.some(tag => Array.isArray(safeArea.tags) && safeArea.tags.includes(tag)) : false const checks = [ { id: 'scope_match', title: 'Profile scope alignment', status: tagMismatch ? 'warn' : 'pass', message: tagMismatch ? `Area tags ${Array.isArray(safeArea.tags) ? safeArea.tags.join(', ') : 'n/a'} do not match profile focus ${safeProfile.targetTags.join(', ')}.` : 'Area tags are compatible with the selected profile.', metrics: { areaTags: Array.isArray(safeArea.tags) ? safeArea.tags : [], targetTags: Array.isArray(safeProfile.targetTags) ? safeProfile.targetTags : [] } }, { id: 'activity', title: 'Area activity', status: activityPct >= Number(safeProfile.minActivityPct || 0) ? 'pass' : (activityPct > 0 ? 'warn' : 'fail'), message: `${activeGaCount}/${totalGAs} GA active in the last ${analysisWindowSec}s.`, metrics: { activeGaCount, totalGAs, activityPct, minActivityPct: Number(safeProfile.minActivityPct || 0) } }, { id: 'silence', title: 'Silent addresses', status: silentPct <= Number(safeProfile.maxSilentPct || 100) ? 'pass' : (silentPct < 100 ? 'warn' : 'fail'), message: `${silentGaCount}/${totalGAs} GA silent in the current analysis window.`, metrics: { silentGaCount, totalGAs, silentPct, maxSilentPct: Number(safeProfile.maxSilentPct || 0) }, sample: silentGAs.slice(0, 10).map(ga => ({ ga, lastPayload: gaLastPayload[ga] || '' })) }, { id: 'anomalies', title: 'Recent anomalies in area', status: relevantAnomalies.length <= Number(safeProfile.maxAnomalies || 0) ? 'pass' : 'warn', message: `${relevantAnomalies.length} recent anomalies match the selected area.`, metrics: { anomalyCount: relevantAnomalies.length, maxAnomalies: Number(safeProfile.maxAnomalies || 0) } } ] const suggestions = [] if (tagMismatch) suggestions.push('Check whether the selected profile is appropriate for this area or add matching tags.') if (activityPct < Number(safeProfile.minActivityPct || 0)) suggestions.push('Run a guided verification on the area or trigger live activity before diagnosing.') if (silentGaCount > 0) suggestions.push('Inspect the silent GA list first: they are the best candidates for missing feedback or dormant devices.') if (relevantAnomalies.length > Number(safeProfile.maxAnomalies || 0)) suggestions.push('Open the anomaly list for this area and correlate the failing GA with the ETS object names.') if (!suggestions.length) suggestions.push('Area looks consistent in read-only mode. Continue with a focused active test only if the issue is still reproducible.') const overallStatus = checks .map(check => check.status) .sort((a, b) => severityRank(b) - severityRank(a))[0] || 'pass' return { id: `${safeProfile.id || 'profile'}:${safeArea.id || 'area'}:${Date.now()}`, generatedAt: generatedAt || new Date().toISOString(), mode: 'read_only', overallStatus, source: { type: 'profile', profile