/** * IT Glue search facade for the AI Ticket Analyzer. * * SECURITY-CRITICAL. Every doc this returns has been run through `redact()` * before leaving the function. Callers must treat the return value as the * only thing that may flow into LLM context, log lines, or analyzer_analyses * rows. The non-redacting `itglue-client.ts` is for non-LLM use only — never * import its results directly into the analyzer pipeline. * * Spec: docs/wulf-pulse-ticket-analyzer-prompt.md → "IT Glue client" + "Stage 2 — IT Glue Retrieval" */ import { getITGlueClient } from '@/lib/services/itglue-client'; import { redact } from './itglue-redact'; import aliases from './itglue-aliases.json'; const MAX_DOCS_RETURNED = 10; const PER_DOC_BODY_CHAR_CAP = 2_000; export interface RedactedDoc { id: string; name: string; doc_type: 'configuration' | 'flexible_asset' | 'document'; organization_id: string; organization_name: string; /** Capped at PER_DOC_BODY_CHAR_CAP; redacted of any sensitive fields. */ snippet: string; /** External link to the doc in IT Glue, if available. */ url: string | null; /** The IT Glue updated-at timestamp, useful for ranking. */ updated_at: string | null; } export interface ITGlueSearchInput { /** Autotask company name from the ticket. Normalized + alias-resolved internally. */ org_name: string; /** Search hints from Stage 1 triage (e.g. ["AMS360 App Access Key", "VSSO admin"]). */ hints: string[]; } export interface ITGlueSearchResult { /** Resolved IT Glue org id, or null if no match. */ org_id: string | null; /** Resolved IT Glue org name, or the input as-is if not resolved. */ org_name: string; docs: RedactedDoc[]; /** Whether the org-name → org-id resolution came from the alias map. */ alias_used: boolean; } function normalize(name: string): string { return name.trim().toLowerCase().replace(/\s+/g, ' '); } /** * Look up an IT Glue org id by Autotask company name. * 1. Check `itglue-aliases.json` for an exact normalized match. * 2. Otherwise, query IT Glue for orgs whose name matches. * Returns null if no match. */ export async function resolveOrgId( orgName: string ): Promise<{ org_id: string | null; org_name: string; alias_used: boolean }> { const key = normalize(orgName); const aliasMap = aliases as Record; const aliasHit = aliasMap[key]; if (aliasHit && !key.startsWith('_')) { return { org_id: aliasHit, org_name: orgName, alias_used: true }; } // Fall through to live IT Glue lookup. We tolerate failures here — the // analyzer should still run without IT Glue context if the lookup errors. try { const client = getITGlueClient(); const orgs = await client.getOrganizations({ name: orgName }); if (orgs.length === 0) return { org_id: null, org_name: orgName, alias_used: false }; const exact = orgs.find((o) => normalize(o.name) === key); const chosen = exact ?? orgs[0]; return { org_id: String(chosen.id), org_name: chosen.name, alias_used: false }; } catch (err) { console.warn( `[itglue-search] org lookup failed for ${JSON.stringify(orgName)}: ${err instanceof Error ? err.message : String(err)}` ); return { org_id: null, org_name: orgName, alias_used: false }; } } /** * Trim a string to the configured cap. Adds an ellipsis marker so the LLM * knows the doc was truncated rather than naturally short. */ function cap(text: string): string { if (text.length <= PER_DOC_BODY_CHAR_CAP) return text; return text.slice(0, PER_DOC_BODY_CHAR_CAP - 20) + '… [truncated]'; } /** * Run a search and return at most MAX_DOCS_RETURNED redacted docs. Every doc * body is capped + redacted before it is returned. The non-redacted IT Glue * payload never escapes this function. */ export async function itglueSearch( input: ITGlueSearchInput ): Promise { const resolved = await resolveOrgId(input.org_name); if (!resolved.org_id) { return { org_id: null, org_name: resolved.org_name, docs: [], alias_used: resolved.alias_used, }; } const client = getITGlueClient(); const docs: RedactedDoc[] = []; const seen = new Set(); // Configurations — usually the most directly applicable type for ticket context. try { const configs = await client.getConfigurations({ organizationId: resolved.org_id }); for (const c of configs) { if (docs.length >= MAX_DOCS_RETURNED) break; const dedupeKey = `cfg:${c.id}`; if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); const redacted = redact({ name: c.name, hostname: c.hostname, primary_ip: c.primaryIp, configuration_type: c.configurationTypeName, manufacturer: c.manufacturerName, model: c.modelName, os: c.operatingSystemName, notes: c.notes ?? '', }); docs.push({ id: String(c.id), name: c.name, doc_type: 'configuration', organization_id: resolved.org_id, organization_name: resolved.org_name, snippet: cap(JSON.stringify(redacted)), url: null, updated_at: c.updatedAt ?? null, }); } } catch (err) { console.warn( `[itglue-search] configurations fetch failed: ${err instanceof Error ? err.message : String(err)}` ); } // Flexible assets — runbooks, integrations, app-specific docs. try { const flex = await client.getFlexibleAssets({ organizationId: resolved.org_id }); for (const a of flex) { if (docs.length >= MAX_DOCS_RETURNED) break; const dedupeKey = `flex:${a.id}`; if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); const redacted = redact({ name: a.name, type: a.flexibleAssetTypeName, traits: a.traits, // any trait keyed like 'password'/'api-key'/etc. is redacted by `redact()` }); docs.push({ id: String(a.id), name: a.name, doc_type: 'flexible_asset', organization_id: resolved.org_id, organization_name: resolved.org_name, snippet: cap(JSON.stringify(redacted)), url: null, updated_at: a.updatedAt ?? null, }); } } catch (err) { console.warn( `[itglue-search] flexible_assets fetch failed: ${err instanceof Error ? err.message : String(err)}` ); } // Hint-based filtering: if hints are provided, prefer docs whose name or // type matches one of the hints (case-insensitive substring). Falls back to // the unsorted result if no hints overlap. if (input.hints.length > 0) { const hintsLower = input.hints.map((h) => h.toLowerCase()); const matchScore = (d: RedactedDoc) => { const haystack = `${d.name} ${d.doc_type}`.toLowerCase(); return hintsLower.reduce((acc, h) => acc + (haystack.includes(h) ? 1 : 0), 0); }; docs.sort((a, b) => matchScore(b) - matchScore(a)); } return { org_id: resolved.org_id, org_name: resolved.org_name, docs: docs.slice(0, MAX_DOCS_RETURNED), alias_used: resolved.alias_used, }; } // Test-only constants. export const _ITGLUE_SEARCH_INTERNALS = { MAX_DOCS_RETURNED, PER_DOC_BODY_CHAR_CAP, };