Files
peardata/test/ai-tools.test.js
T
Raven Scott 4b5b78892f
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s
FIX: QVAC Updates
2026-07-30 15:23:43 -04:00

270 lines
8.9 KiB
JavaScript

import test from 'brittle'
import { Methods, MethodRoles, Roles } from '../shared/protocol.js'
import { validateMethodArgs } from '../shared/schema.js'
import { suggestProfile, getProfile, QVAC_PROFILES } from '../ui/qvac/profiles.js'
import { buildSystemPrompt } from '../ui/qvac/prompts.js'
import { buildRagContext, retrieve, catalogDocs, GUIDE_DOCS } from '../ui/qvac/rag.js'
import { getStore } from '../server/services/store.js'
import {
searchCharts,
summarizeChart,
getHostSnapshot,
investigateHost,
hotMetrics,
relatedCharts,
compareChartWindows,
summarizeCharts,
} from '../server/services/ai-tools.js'
import { initAuthKeys } from '../server/core/auth-keys.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
const seed = crypto.randomBytes(32)
initAuthKeys({
seedHex: b4a.toString(seed, 'hex'),
publicKeyHex: b4a.toString(crypto.keyPair(seed).publicKey, 'hex'),
})
const AI_METHODS = [
'getHostSnapshot',
'searchCharts',
'summarizeChart',
'investigateHost',
'hotMetrics',
'relatedCharts',
'compareChartWindows',
'summarizeCharts',
]
test('AI composite methods are viewer role', (t) => {
for (const m of AI_METHODS) {
t.is(MethodRoles[m], Roles.viewer, m)
t.is(Methods[m], m)
}
})
test('schema validates searchCharts + summarizeChart + deep AI', (t) => {
t.ok(validateMethodArgs('getHostSnapshot', {}).ok)
t.ok(validateMethodArgs('investigateHost', {}).ok)
const s = validateMethodArgs('searchCharts', { q: 'cpu', limit: 10 })
t.ok(s.ok)
t.is(s.args.q, 'cpu')
const bad = validateMethodArgs('searchCharts', { limit: 999 })
t.absent(bad.ok)
const sum = validateMethodArgs('summarizeChart', { chart: 'system.cpu', points: 60 })
t.ok(sum.ok)
t.is(sum.args.chart, 'system.cpu')
const miss = validateMethodArgs('summarizeChart', {})
t.absent(miss.ok)
const hot = validateMethodArgs('hotMetrics', { limit: 10, window: 60 })
t.ok(hot.ok)
const hotBad = validateMethodArgs('hotMetrics', { window: 5 })
t.absent(hotBad.ok)
const rel = validateMethodArgs('relatedCharts', { chart: 'system.cpu' })
t.ok(rel.ok)
const relMiss = validateMethodArgs('relatedCharts', {})
t.absent(relMiss.ok)
const cmp = validateMethodArgs('compareChartWindows', { chart: 'system.cpu', points: 40 })
t.ok(cmp.ok)
const batch = validateMethodArgs('summarizeCharts', {
charts: ['system.cpu', 'system.ram'],
points: 30,
})
t.ok(batch.ok)
t.is(batch.args.charts.length, 2)
const batchStr = validateMethodArgs('summarizeCharts', { charts: 'system.cpu,system.ram' })
t.ok(batchStr.ok)
t.is(batchStr.args.charts.length, 2)
})
test('QVAC profiles suggest by RAM', (t) => {
t.is(suggestProfile({ totalRamBytes: 4e9 }), 'lite')
t.is(suggestProfile({ totalRamBytes: 10e9 }), 'recommended')
t.is(suggestProfile({ totalRamBytes: 32e9 }), 'strong')
t.ok(getProfile('recommended').tools)
t.ok(QVAC_PROFILES.lite.chatModel.includes('QWEN'))
})
test('system prompt forbids inventing metrics and mentions investigate', (t) => {
const p = buildSystemPrompt({ peerAlias: 'lab', role: 'viewer', connected: true })
t.ok(p.includes('Never invent'))
t.ok(p.includes('lab'))
t.ok(p.includes('viewer'))
t.ok(p.includes('investigate_host'))
})
test('RAG retrieves guide + catalog', (t) => {
t.ok(GUIDE_DOCS.length >= 5)
const hits = retrieve('charts time presets pin', GUIDE_DOCS, 3)
t.ok(hits.length >= 1)
t.ok(hits[0].score > 0)
const cat = catalogDocs({
'system.cpu': { title: 'CPU', context: 'system.cpu', family: 'cpu', units: '%' },
'disk_io.sda': { title: 'Disk sda', context: 'disk', family: 'io' },
})
t.is(cat.length, 2)
const ctx = buildRagContext({ query: 'disk io', catalog: { 'disk_io.sda': { title: 'Disk' } } })
t.ok(ctx.includes('disk') || ctx.includes('Disk') || ctx.includes('local knowledge'))
})
test('searchCharts and summarizeChart against store', async (t) => {
const now = Date.now()
for (let i = 0; i < 8; i++) {
getStore().ingest([
{
chart: 'system.cpu',
context: 'system.cpu',
ts: now - (8 - i) * 1000,
values: {
user: 10 + i,
system: 2,
nice: 0,
iowait: 0,
irq: 0,
softirq: 0,
idle: 80 - i,
},
},
])
}
const found = searchCharts({ q: 'cpu', limit: 20 })
t.ok(found.results.some((r) => r.id === 'system.cpu'))
const sum = await summarizeChart({ chart: 'system.cpu', after: -30, points: 20 })
t.is(sum.chart, 'system.cpu')
t.ok(sum.dims)
const snap = await getHostSnapshot()
t.ok(snap.hostname)
t.ok(snap.kpis)
const inv = await investigateHost({ processLimit: 5, hotLimit: 8 })
t.ok(inv.summary)
t.ok(Array.isArray(inv.findings))
t.ok(inv.kpis)
const hot = await hotMetrics({ limit: 10, window: 60 })
t.ok(Array.isArray(hot.results))
const batch = await summarizeCharts({ charts: ['system.cpu'], after: -30, points: 20 })
t.ok(batch.results?.some((r) => r.chart === 'system.cpu' && r.dims))
const cmp = await compareChartWindows({ chart: 'system.cpu', points: 20 })
t.ok(cmp.delta || cmp.error == null)
t.ok(cmp.highlight || cmp.error)
// related may be empty without full catalog entry — should not throw
const rel = await relatedCharts({ chart: 'system.cpu', limit: 5 })
t.ok(rel.chart === 'system.cpu' || rel.error)
})
test('tool runner gates offline + silence role + tiers', async (t) => {
const {
createToolRunner,
fallbackComplete,
TOOL_DEFS,
toolDepthForProfile,
} = await import('../ui/qvac/tools.js')
// Flat @qvac/sdk shape (not nested function.name)
t.ok(TOOL_DEFS.some((d) => d.name === 'host_snapshot'))
t.ok(TOOL_DEFS.some((d) => d.name === 'investigate_host'))
t.ok(TOOL_DEFS.some((d) => d.name === 'hot_metrics'))
t.ok(TOOL_DEFS.some((d) => d.name === 'fleet_health'))
t.ok(TOOL_DEFS.every((d) => d.type === 'function' && d.name && d.parameters))
t.absent(TOOL_DEFS.some((d) => d.function))
t.is(toolDepthForProfile('tool-tiny'), 'core')
t.is(toolDepthForProfile('recommended'), 'deep')
t.is(toolDepthForProfile('strong'), 'deep')
let connected = false
/** @type {string} */
let role = 'viewer'
const calls = []
const tools = createToolRunner({
manager: {
request: async (m, a) => {
calls.push({ m, a })
if (m === 'getHostSnapshot') {
return { hostname: 'lab', health: { status: 'ok' }, kpis: {} }
}
if (m === 'investigateHost') {
return {
hostname: 'lab',
summary: { findingCount: 0, topSeverity: 'ok', health: 'ok' },
findings: [],
kpis: {},
}
}
if (m === 'listAnomalies') return { anomalies: [] }
if (m === 'hotMetrics') return { window: 120, count: 0, results: [] }
if (m === 'listAlerts') return { alerts: [] }
if (m === 'getHealth') return { status: 'ok' }
if (m === 'listJobs') return { jobs: [] }
return { ok: true }
},
active: null,
},
getRole: () => role,
isConnected: () => connected,
getCatalog: () => ({ 'system.cpu': { title: 'CPU' } }),
})
const offline = await tools.run('host_snapshot', {})
t.ok(offline.error)
t.ok(String(offline.error).toLowerCase().includes('connect'))
connected = true
const snap = await tools.run('host_snapshot', {})
t.is(snap.hostname, 'lab')
const inv = await tools.run('investigate_host', {})
t.is(inv.hostname, 'lab')
t.ok(inv.summary)
const silence = await tools.run('silence_alert', { id: 'a1', confirmed: true })
t.ok(String(silence.error || '').includes('Operator') || silence.error)
role = 'operator'
const silence2 = await tools.run('silence_alert', { id: 'a1', confirmed: false })
t.is(silence2.error, 'confirmation_required')
role = 'viewer'
const defsViewer = tools.defsForRole({ profileId: 'recommended' }).map((d) => d.name)
t.absent(defsViewer.includes('silence_alert'))
t.ok(defsViewer.includes('investigate_host'))
t.ok(defsViewer.includes('hot_metrics'))
t.ok(defsViewer.includes('related_charts'))
const defsTiny = tools.defsForRole({ profileId: 'tool-tiny' }).map((d) => d.name)
t.ok(defsTiny.includes('investigate_host'))
t.absent(defsTiny.includes('hot_metrics'))
t.absent(defsTiny.includes('query_metric'))
role = 'operator'
const defsOp = tools.defsForRole({ profileId: 'strong' }).map((d) => d.name)
t.ok(defsOp.includes('silence_alert'))
t.ok(defsOp.includes('ack_alert'))
t.ok(defsOp.includes('run_job'))
// Wire shape has no internal tier field
t.ok(tools.defsForRole().every((d) => d.tier == null))
const kn = await tools.run('local_knowledge', { q: 'charts' })
t.ok(kn.context)
const fb = await fallbackComplete('any open alerts?', tools)
t.is(fb.mode, 'fallback')
t.ok(fb.contentText)
t.ok((fb.toolCalls || []).length >= 1)
const fb2 = await fallbackComplete("what's wrong with this host", tools)
t.ok((fb2.toolCalls || []).some((c) => c.name === 'investigate_host'))
})