wulf-pulse/lib/services/analyzer/itglue-search.ts
lorentz 8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:59:40 -04:00

208 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,
};