209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, string>;
|
||
|
|
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<ITGlueSearchResult> {
|
||
|
|
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<string>();
|
||
|
|
|
||
|
|
// 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,
|
||
|
|
};
|