/** * Data builder for the IT Glue asset audit pipeline. * * Pulls every input the LLM needs to evaluate one IT Glue record (Application * flexible asset OR Configuration) against ticket history and IT Glue's own * field schema: * * 1. The asset's current contents (redacted). * 2. The field schema with hints (IT Glue's per-field documentation, or * curated hints for Configurations which don't expose a *_fields table). * 3. Peer exemplars from the same client — well-filled assets of the same * type, redacted. * 4. Best-in-class exemplars across all clients of the same type. * 5. Per-field fill-rate stats (per-client + global). * 6. Recent ticket fingerprints whose applications/devices overlap the * asset, with summaries. * * Phase 4.1 additions: * - assetType dispatch: 'flexible_asset' | 'configuration' * - ticketScopeAnalysisId: when set, ticket evidence is the single source * analysis only, so the LLM looks for what *this ticket* taught us. */ import postgresClient from '@/lib/services/postgres-client'; import { redact } from '../itglue-redact'; import { listLatestEvidenceForAsset, listLatestEvidenceForCompany, } from '@/lib/services/rmm/persistence'; // ─── Types ──────────────────────────────────────────────────────────────── export type AuditAssetType = 'flexible_asset' | 'configuration'; /** * Generic asset shape the prompt consumes. For flexible assets, `traits` is * the IT Glue traits blob. For configurations, `traits` is a synthesized * map of editable columns → current values, so the prompt stays * type-agnostic. */ export interface AuditAssetRow { id: string; organization_id: string | null; organization_name: string | null; type_id: string | null; type_name: string | null; name: string | null; traits: Record; } export interface AuditFieldRow { id: string; name: string; kind: string | null; hint: string | null; required: boolean; } export interface FillRateRow { field_name: string; fill_rate: number; // 0..1 } export interface TicketEvidenceRow { ticket_number: string; triggered_at: string; summary: string | null; fingerprint: Record; } /** * Phase 4.2: live evidence captured by an Overshell execution. The audit * pipeline injects this as a 7th LLM context arm. */ export interface RmmEvidenceRow { execution_id: string; script_id: string; target_type: 'site_anchor' | 'asset_self'; target_hostname: string | null; captured_at: string; parsed: unknown; } export interface AssetAuditContext { asset_type: AuditAssetType; asset: AuditAssetRow; type_id: number | null; type_name: string | null; fields: AuditFieldRow[]; peer_same_client: AuditAssetRow[]; peer_global: AuditAssetRow[]; fill_rate_client: FillRateRow[]; fill_rate_global: FillRateRow[]; ticket_evidence: TicketEvidenceRow[]; /** Set when the audit was launched from a single ticket's analysis. */ ticket_scope: { analysis_id: string; ticket_number: string } | null; rmm_evidence: RmmEvidenceRow[]; } // ─── Tunables ───────────────────────────────────────────────────────────── const PEER_SAME_CLIENT_LIMIT = 5; const PEER_GLOBAL_LIMIT = 3; const TICKET_EVIDENCE_LIMIT = 20; // ─── Errors ─────────────────────────────────────────────────────────────── export class AssetAuditNotFoundError extends Error { constructor(assetType: AuditAssetType, assetId: string | number) { super(`${assetType} ${assetId} not found in mirror`); this.name = 'AssetAuditNotFoundError'; } } // ─── Helpers ────────────────────────────────────────────────────────────── function fillCount(traits: Record | null | undefined): number { if (!traits) return 0; let n = 0; for (const k of Object.keys(traits)) { const v = traits[k]; if (v === null || v === undefined) continue; if (typeof v === 'string' && v.trim().length === 0) continue; if (Array.isArray(v) && v.length === 0) continue; n += 1; } return n; } // ─── Flexible-asset loaders ─────────────────────────────────────────────── interface RawFlexAsset { id: string; organization_id: string | null; organization_name: string | null; flexible_asset_type_id: string; flexible_asset_type_name: string | null; name: string | null; traits: Record | null; } function flexAssetToAudit(r: RawFlexAsset): AuditAssetRow { return { id: r.id, organization_id: r.organization_id, organization_name: r.organization_name, type_id: r.flexible_asset_type_id, type_name: r.flexible_asset_type_name, name: r.name, traits: r.traits ?? {}, }; } async function loadFlexAsset(assetId: string | number): Promise { const res = await postgresClient.query( `SELECT id::text AS id, organization_id::text AS organization_id, organization_name, flexible_asset_type_id::text AS flexible_asset_type_id, flexible_asset_type_name, name, traits FROM itg_flexible_assets WHERE id = $1 AND COALESCE(archived, false) = false LIMIT 1`, [assetId] ); if (res.rowCount === 0) throw new AssetAuditNotFoundError('flexible_asset', assetId); const r = res.rows[0]; return { ...flexAssetToAudit(r), _raw: r }; } async function loadFlexFields(typeId: string): Promise { const res = await postgresClient.query( `SELECT id::text AS id, name, kind, hint, required FROM itg_flexible_asset_fields WHERE flexible_asset_type_id = $1 ORDER BY id`, [typeId] ); return res.rows; } async function loadFlexPeerSameClient( orgId: string | null, typeId: string, excludeId: string, limit: number ): Promise { if (!orgId) return []; const res = await postgresClient.query( `SELECT id::text AS id, organization_id::text AS organization_id, organization_name, flexible_asset_type_id::text AS flexible_asset_type_id, flexible_asset_type_name, name, traits FROM itg_flexible_assets WHERE organization_id = $1 AND flexible_asset_type_id = $2 AND id <> $3 AND COALESCE(archived, false) = false`, [orgId, typeId, excludeId] ); const rows = res.rows.map(flexAssetToAudit); rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits)); return rows.slice(0, limit); } async function loadFlexPeerGlobal( typeId: string, excludeId: string, excludeOrgId: string | null, limit: number ): Promise { const res = await postgresClient.query( `SELECT id::text AS id, organization_id::text AS organization_id, organization_name, flexible_asset_type_id::text AS flexible_asset_type_id, flexible_asset_type_name, name, traits FROM itg_flexible_assets WHERE flexible_asset_type_id = $1 AND id <> $2 AND ($3::text IS NULL OR organization_id::text <> $3) AND COALESCE(archived, false) = false ORDER BY synced_at DESC LIMIT 200`, [typeId, excludeId, excludeOrgId] ); const rows = res.rows.map(flexAssetToAudit); rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits)); return rows.slice(0, limit); } function flexFieldKey(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } async function loadFlexFillRates( typeId: string, orgIdFilter: string | null, fields: AuditFieldRow[] ): Promise { const params: unknown[] = [typeId]; let where = `flexible_asset_type_id = $1 AND COALESCE(archived, false) = false`; if (orgIdFilter) { params.push(orgIdFilter); where += ` AND organization_id = $${params.length}`; } const res = await postgresClient.query<{ traits: Record | null }>( `SELECT traits FROM itg_flexible_assets WHERE ${where}`, params ); const total = res.rowCount ?? 0; if (total === 0) return fields.map((f) => ({ field_name: f.name, fill_rate: 0 })); return fields.map((f) => { const key = flexFieldKey(f.name); let filled = 0; for (const row of res.rows) { const v = row.traits?.[key]; if (v === null || v === undefined) continue; if (typeof v === 'string' && v.trim().length === 0) continue; if (Array.isArray(v) && v.length === 0) continue; if ( typeof v === 'object' && v !== null && 'values' in (v as Record) && Array.isArray((v as { values: unknown[] }).values) && (v as { values: unknown[] }).values.length === 0 ) { continue; } filled += 1; } return { field_name: f.name, fill_rate: Math.round((filled / total) * 100) / 100, }; }); } // ─── Configuration loaders ──────────────────────────────────────────────── interface RawConfiguration { id: string; organization_id: string | null; organization_name: string | null; configuration_type_id: string | null; configuration_type_name: string | null; configuration_status_id: string | null; configuration_status_name: string | null; manufacturer_id: string | null; manufacturer_name: string | null; model_id: string | null; model_name: string | null; operating_system_id: string | null; operating_system_name: string | null; contact_id: string | null; location_id: string | null; name: string; hostname: string | null; primary_ip: string | null; mac_address: string | null; serial_number: string | null; asset_tag: string | null; position: string | null; notes: string | null; operating_system_notes: string | null; } const CONFIGURATION_FIELDS: AuditFieldRow[] = [ { id: '1', name: 'name', kind: 'Text', hint: 'Display name. Match the hostname or a stable label techs recognize.', required: true }, { id: '2', name: 'hostname', kind: 'Text', hint: 'FQDN or NetBIOS name as it appears on the network. Should match what shows up in DNS and on the asset itself.', required: false }, { id: '3', name: 'primary_ip', kind: 'Text', hint: 'Primary IP address. Static where possible; capture even if DHCP-assigned for current state.', required: false }, { id: '4', name: 'mac_address', kind: 'Text', hint: 'Primary network adapter MAC.', required: false }, { id: '5', name: 'serial_number', kind: 'Text', hint: 'Hardware serial / VM UUID — needed for vendor warranty calls.', required: false }, { id: '6', name: 'asset_tag', kind: 'Text', hint: 'Physical or logical asset tag if the customer uses one.', required: false }, { id: '7', name: 'position', kind: 'Text', hint: 'Rack U position, room location, or VM cluster placement.', required: false }, { id: '8', name: 'configuration_type_name', kind: 'Select', hint: 'Server, Workstation, Printer, Firewall, Switch, etc. Drives downstream filtering and audit prompts.', required: false }, { id: '9', name: 'configuration_status_name', kind: 'Select', hint: 'Active, Inactive, Decommissioned, Spare. Stale Active records hide retired infra.', required: false }, { id: '10', name: 'manufacturer_name', kind: 'Tag', hint: 'Hardware/VM platform vendor (Dell, HPE, VMware, Hyper-V).', required: false }, { id: '11', name: 'model_name', kind: 'Tag', hint: 'Model identifier (PowerEdge R650, Surface Laptop 5).', required: false }, { id: '12', name: 'operating_system_name', kind: 'Tag', hint: 'OS + version (Windows Server 2019, Ubuntu 22.04). Stale OS = stale patch picture.', required: false }, { id: '13', name: 'operating_system_notes', kind: 'Textbox', hint: 'OS-specific gotchas: hotfix levels, schedule windows, named services and what they do, important roles installed (DC, DHCP, DNS, RDS).', required: false }, { id: '14', name: 'notes', kind: 'Textbox', hint: 'Free-text general notes. Keep architectural details (integration paths, data flows) here only if no structured field fits.', required: false }, { id: '15', name: 'contact_id', kind: 'Tag', hint: 'Primary user / responsible contact (workstation = end user; server = champion).', required: false }, { id: '16', name: 'location_id', kind: 'Tag', hint: 'IT Glue location/site this configuration lives at.', required: false }, ]; function configToAudit(r: RawConfiguration): AuditAssetRow { // Synthesize a traits-style map for prompt consistency. const traits: Record = { name: r.name ?? null, hostname: r.hostname ?? null, primary_ip: r.primary_ip ?? null, mac_address: r.mac_address ?? null, serial_number: r.serial_number ?? null, asset_tag: r.asset_tag ?? null, position: r.position ?? null, configuration_type_name: r.configuration_type_name ?? null, configuration_status_name: r.configuration_status_name ?? null, manufacturer_name: r.manufacturer_name ?? null, model_name: r.model_name ?? null, operating_system_name: r.operating_system_name ?? null, operating_system_notes: r.operating_system_notes ?? null, notes: r.notes ?? null, contact_id: r.contact_id ?? null, location_id: r.location_id ?? null, }; return { id: r.id, organization_id: r.organization_id, organization_name: r.organization_name, type_id: r.configuration_type_id, type_name: r.configuration_type_name, name: r.name, traits, }; } const CONFIG_SELECT = ` id::text AS id, organization_id::text AS organization_id, organization_name, configuration_type_id::text AS configuration_type_id, configuration_type_name, configuration_status_id::text AS configuration_status_id, configuration_status_name, manufacturer_id::text AS manufacturer_id, manufacturer_name, model_id::text AS model_id, model_name, operating_system_id::text AS operating_system_id, operating_system_name, contact_id::text AS contact_id, location_id::text AS location_id, name, hostname, primary_ip, mac_address, serial_number, asset_tag, position, notes, operating_system_notes `; async function loadConfiguration(assetId: string | number): Promise { const res = await postgresClient.query( `SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE id = $1 LIMIT 1`, [assetId] ); if (res.rowCount === 0) throw new AssetAuditNotFoundError('configuration', assetId); return { ...configToAudit(res.rows[0]), type_id_str: res.rows[0].configuration_type_id }; } async function loadConfigPeerSameClient( orgId: string | null, typeId: string | null, excludeId: string, limit: number ): Promise { if (!orgId || !typeId) return []; const res = await postgresClient.query( `SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE organization_id = $1 AND configuration_type_id = $2 AND id <> $3`, [orgId, typeId, excludeId] ); const rows = res.rows.map(configToAudit); rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits)); return rows.slice(0, limit); } async function loadConfigPeerGlobal( typeId: string | null, excludeId: string, excludeOrgId: string | null, limit: number ): Promise { if (!typeId) return []; const res = await postgresClient.query( `SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE configuration_type_id = $1 AND id <> $2 AND ($3::text IS NULL OR organization_id::text <> $3) LIMIT 200`, [typeId, excludeId, excludeOrgId] ); const rows = res.rows.map(configToAudit); rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits)); return rows.slice(0, limit); } async function loadConfigFillRates( typeId: string | null, orgIdFilter: string | null ): Promise { if (!typeId) { return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 })); } const params: unknown[] = [typeId]; let where = `configuration_type_id = $1`; if (orgIdFilter) { params.push(orgIdFilter); where += ` AND organization_id = $${params.length}`; } const res = await postgresClient.query( `SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE ${where}`, params ); const total = res.rowCount ?? 0; if (total === 0) { return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 })); } return CONFIGURATION_FIELDS.map((f) => { let filled = 0; for (const row of res.rows) { const v = (row as unknown as Record)[f.name]; if (v === null || v === undefined) continue; if (typeof v === 'string' && v.trim().length === 0) continue; filled += 1; } return { field_name: f.name, fill_rate: Math.round((filled / total) * 100) / 100, }; }); } // ─── Ticket evidence (shared) ──────────────────────────────────────────── async function loadTicketEvidence( orgId: string | null, assetName: string | null, limit: number ): Promise { if (!orgId || !assetName) return []; const compRes = await postgresClient.query<{ id: string }>( `SELECT c.id::text AS id FROM companies c JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name) WHERE o.id = $1 LIMIT 1`, [orgId] ); if (compRes.rowCount === 0) return []; const companyId = compRes.rows[0].id; const lowerName = assetName.toLowerCase(); const res = await postgresClient.query<{ ticket_number: string; triggered_at: Date; summary: string | null; fingerprint: Record | null; }>( `SELECT aa.ticket_number, aa.triggered_at, aa.summary, aa.aggregate_fingerprint AS fingerprint FROM analyzer_analyses aa JOIN tickets t ON t.ticket_number = aa.ticket_number WHERE t.company_id = $1 AND aa.status = 'complete' AND aa.aggregate_fingerprint IS NOT NULL AND ( LOWER(COALESCE(aa.summary, '')) LIKE '%' || $2 || '%' OR aa.aggregate_fingerprint::text ILIKE '%' || $2 || '%' ) ORDER BY aa.triggered_at DESC LIMIT $3`, [companyId, lowerName, limit] ); return res.rows.map((r) => ({ ticket_number: r.ticket_number, triggered_at: r.triggered_at.toISOString(), summary: r.summary, fingerprint: r.fingerprint ?? {}, })); } /** * Ticket-scoped evidence — returns just the one analysis the audit was * launched from. Used when the user clicks "Check IT Glue documentation" * on a specific analysis page; the audit looks for what *this ticket* * taught us, not all-time history. */ /** * Phase 4.2: pull the latest successful Overshell evidence for the audit's * client + asset. Site-anchored scripts: latest per script_id within 7 * days. Asset-self scripts: latest per script_id, no time limit * (asset-self facts are durable until something changes). */ async function loadRmmEvidence( itglueOrgId: string | null, assetType: AuditAssetType, assetId: string ): Promise { if (!itglueOrgId) return []; // Map IT Glue org → Autotask company (same join the ticket-evidence loader uses). const compRes = await postgresClient.query<{ id: string }>( `SELECT c.id::text AS id FROM companies c JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name) WHERE o.id = $1 LIMIT 1`, [itglueOrgId] ); if (compRes.rowCount === 0) return []; const companyId = compRes.rows[0].id; const [siteAnchored, assetSelf] = await Promise.all([ listLatestEvidenceForCompany(companyId, 7), listLatestEvidenceForAsset(assetType, assetId), ]); // Combine: site-anchored first (general context), then asset-self. const out: RmmEvidenceRow[] = []; for (const e of siteAnchored) { if (e.targetType !== 'site_anchor') continue; out.push({ execution_id: e.id, script_id: e.scriptId, target_type: e.targetType, target_hostname: e.targetHostname, captured_at: e.completedAt ?? e.queuedAt, parsed: redact(e.parsedEvidence ?? null), }); } for (const e of assetSelf) { out.push({ execution_id: e.id, script_id: e.scriptId, target_type: e.targetType, target_hostname: e.targetHostname, captured_at: e.completedAt ?? e.queuedAt, parsed: redact(e.parsedEvidence ?? null), }); } return out; } async function loadSingleTicketEvidence( analysisId: string ): Promise { const res = await postgresClient.query<{ ticket_number: string; triggered_at: Date; summary: string | null; fingerprint: Record | null; }>( `SELECT ticket_number, triggered_at, summary, aggregate_fingerprint AS fingerprint FROM analyzer_analyses WHERE id = $1 AND status = 'complete' LIMIT 1`, [analysisId] ); if (res.rowCount === 0) return []; const r = res.rows[0]; return [ { ticket_number: r.ticket_number, triggered_at: r.triggered_at.toISOString(), summary: r.summary, fingerprint: r.fingerprint ?? {}, }, ]; } // ─── Top-level builder ──────────────────────────────────────────────────── export interface BuildAssetAuditContextInput { assetType: AuditAssetType; assetId: string | number; /** When set, ticket evidence is just this one analysis (ticket-first mode). */ ticketScopeAnalysisId?: string; } export async function buildAssetAuditContext( input: BuildAssetAuditContextInput ): Promise { const { assetType, assetId, ticketScopeAnalysisId } = input; if (assetType === 'flexible_asset') { const asset = await loadFlexAsset(assetId); const typeId = asset.type_id ?? ''; const fields = await loadFlexFields(typeId); const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] = await Promise.all([ loadFlexPeerSameClient(asset.organization_id, typeId, asset.id, PEER_SAME_CLIENT_LIMIT), loadFlexPeerGlobal(typeId, asset.id, asset.organization_id, PEER_GLOBAL_LIMIT), loadFlexFillRates(typeId, asset.organization_id, fields), loadFlexFillRates(typeId, null, fields), loadRmmEvidence(asset.organization_id, 'flexible_asset', asset.id), ]); let ticketEvidence: TicketEvidenceRow[]; let ticketScope: AssetAuditContext['ticket_scope'] = null; if (ticketScopeAnalysisId) { ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId); if (ticketEvidence.length > 0) { ticketScope = { analysis_id: ticketScopeAnalysisId, ticket_number: ticketEvidence[0].ticket_number, }; } } else { ticketEvidence = await loadTicketEvidence(asset.organization_id, asset.name, TICKET_EVIDENCE_LIMIT); } return { asset_type: 'flexible_asset', asset: { ...asset, traits: redact(asset.traits) }, type_id: Number(typeId) || null, type_name: asset.type_name, fields, peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })), peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })), fill_rate_client: fillRateClient, fill_rate_global: fillRateGlobal, ticket_evidence: ticketEvidence, ticket_scope: ticketScope, rmm_evidence: rmmEvidence, }; } // Configurations const config = await loadConfiguration(assetId); const typeId = config.type_id_str; const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] = await Promise.all([ loadConfigPeerSameClient(config.organization_id, typeId, config.id, PEER_SAME_CLIENT_LIMIT), loadConfigPeerGlobal(typeId, config.id, config.organization_id, PEER_GLOBAL_LIMIT), loadConfigFillRates(typeId, config.organization_id), loadConfigFillRates(typeId, null), loadRmmEvidence(config.organization_id, 'configuration', config.id), ]); let ticketEvidence: TicketEvidenceRow[]; let ticketScope: AssetAuditContext['ticket_scope'] = null; if (ticketScopeAnalysisId) { ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId); if (ticketEvidence.length > 0) { ticketScope = { analysis_id: ticketScopeAnalysisId, ticket_number: ticketEvidence[0].ticket_number, }; } } else { // For configurations, also try matching on hostname for richer evidence. const matchTerm = config.name ?? (config.traits.hostname as string | null) ?? null; ticketEvidence = await loadTicketEvidence(config.organization_id, matchTerm, TICKET_EVIDENCE_LIMIT); } return { asset_type: 'configuration', asset: { ...config, traits: redact(config.traits) }, type_id: typeId !== null ? Number(typeId) : null, type_name: config.type_name, fields: CONFIGURATION_FIELDS, peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })), peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })), fill_rate_client: fillRateClient, fill_rate_global: fillRateGlobal, ticket_evidence: ticketEvidence, ticket_scope: ticketScope, rmm_evidence: rmmEvidence, }; } // Exported for tests + observability. export const _ASSET_AUDIT_INTERNALS = { PEER_SAME_CLIENT_LIMIT, PEER_GLOBAL_LIMIT, TICKET_EVIDENCE_LIMIT, CONFIGURATION_FIELDS, fillCount, };