wulf-pulse/lib/services/analyzer/link-discovery.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

441 lines
14 KiB
TypeScript

/**
* Link discovery for the AI Ticket Analyzer.
*
* Given a ticket bundle (from data-access.loadTicketBundle), find every other
* ticket the analyzer should bundle in. Two arms:
*
* 1. Explicit (cheap, deterministic): regex over the description + each
* retained note for ticket-number references, recognition of the
* structured "RELATED TICKETS:" block, and the ticket's
* problem_ticket_id column. No LLM calls.
*
* 2. Suggested (Haiku, opt-in): one LLM pass over recent same-company
* tickets ranking semantic similarity to the master.
*
* Returns refs paired with confidence + source so the UI can surface the
* provenance of each suggestion.
*/
import postgresClient from '@/lib/services/postgres-client';
import {
type DiscoveredLinks,
type LinkConfidence,
type LinkSource,
type TicketRef,
} from '@/lib/types/analyzer';
import type { RawTicketBundle } from './preprocessor';
import { isWorkflowNoise, isEmailNotification } from './preprocessor';
import { callLLMStage } from '@/lib/services/llm/call';
import { HAIKU } from '@/lib/services/llm/models';
import { z } from 'zod';
/**
* Pulse ticket-number format: T<YYYYMMDD>.<####>. Confirmed against
* tickets.ticket_number in migration 001 and Autotask's webhook payloads.
*/
export const TICKET_NUMBER_REGEX = /T\d{8}\.\d{4}/g;
export const MAX_EXPLICIT_LINKS = 15;
export const MAX_SUGGESTED_LINKS = 5;
const SUGGESTED_CANDIDATE_LIMIT = 50;
const SUGGESTED_CANDIDATE_DAYS = 30;
const SUGGESTED_DESCRIPTION_CHAR_CAP = 1024;
const SUGGESTED_MAX_TOKENS = 1500;
interface RawRef {
ticket_number: string;
source: LinkSource;
confidence: LinkConfidence;
}
interface ExtractedExplicit {
refs: RawRef[];
hasRelatedTicketsSection: boolean;
}
/**
* Pull every T-number out of a single chunk of free text. Refs that appear
* inside (or directly after) the literal "RELATED TICKETS:" header are flagged
* 'high' confidence; others are 'medium'.
*/
export function extractExplicitFromText(
text: string,
source: LinkSource
): ExtractedExplicit {
if (!text) return { refs: [], hasRelatedTicketsSection: false };
const refs: RawRef[] = [];
// Detect a "RELATED TICKETS:" block: header line, followed by lines containing
// T-numbers, until either an empty line or a new section header (UPPER CASE
// followed by colon at start of line).
const sectionHeaderMatch = /^[ \t]*RELATED TICKETS\s*:?\s*$/im.exec(text);
let sectionRefs = new Set<string>();
if (sectionHeaderMatch && sectionHeaderMatch.index !== undefined) {
const after = text.slice(
sectionHeaderMatch.index + sectionHeaderMatch[0].length
);
// Lookahead: stop at next blank line, or at a line that looks like a new
// ALL-CAPS section header. This is permissive — the format we've seen at
// Wulf is `T20260428.0053 — note text\nT20260427.0142 — note text\n\n`.
const sectionEnd = after.search(/\n\s*\n|\n[A-Z][A-Z _]+:/);
const section = sectionEnd === -1 ? after : after.slice(0, sectionEnd);
const matches = section.match(TICKET_NUMBER_REGEX) ?? [];
sectionRefs = new Set(matches);
}
const allMatches = text.match(TICKET_NUMBER_REGEX) ?? [];
const seen = new Set<string>();
for (const num of allMatches) {
if (seen.has(num)) continue;
seen.add(num);
if (sectionRefs.has(num)) {
refs.push({
ticket_number: num,
source: 'related_tickets_section',
confidence: 'high',
});
} else {
refs.push({ ticket_number: num, source, confidence: 'medium' });
}
}
return {
refs,
hasRelatedTicketsSection: sectionRefs.size > 0,
};
}
/**
* Look at the title + description for hints that this is a master/problem
* ticket. Used purely as a UI signal — does not gate any behavior.
*/
export function detectProblemTicket(
bundle: RawTicketBundle,
hasRelatedTicketsSection: boolean
): { isProblemTicket: boolean; signals: string[] } {
const signals: string[] = [];
const title = (bundle.ticket.title ?? '').toLowerCase();
if (title.includes('master problem ticket')) signals.push('title:master_problem_ticket');
else if (title.includes('problem ticket')) signals.push('title:problem_ticket');
if (hasRelatedTicketsSection) signals.push('description:related_tickets_section');
if (bundle.ticket.problem_ticket_id !== null) signals.push('column:problem_ticket_id');
return { isProblemTicket: signals.length > 0, signals };
}
interface MetaRow {
ticket_number: string;
title: string | null;
status_label: string | null;
last_activity_date: Date | string | null;
}
async function loadTicketMeta(
ticketNumbers: string[]
): Promise<Map<string, MetaRow>> {
const out = new Map<string, MetaRow>();
if (ticketNumbers.length === 0) return out;
const res = await postgresClient.query<{
ticket_number: string;
title: string | null;
status_label: string | null;
last_activity_date: Date | string | null;
}>(
`SELECT t.ticket_number,
t.title,
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
t.last_activity_date
FROM tickets t
WHERE t.ticket_number = ANY($1::text[])
AND COALESCE(t.is_deleted, false) = false`,
[ticketNumbers]
);
for (const r of res.rows) out.set(r.ticket_number, r);
return out;
}
async function resolveProblemTicketNumber(
problemTicketId: number
): Promise<string | null> {
const res = await postgresClient.query<{ ticket_number: string }>(
`SELECT ticket_number FROM tickets
WHERE id = $1 AND COALESCE(is_deleted, false) = false LIMIT 1`,
[problemTicketId]
);
return res.rowCount === 0 ? null : res.rows[0].ticket_number;
}
function toIsoOrNull(d: Date | string | null): string | null {
if (d === null || d === undefined) return null;
if (d instanceof Date) return d.toISOString();
return new Date(d).toISOString();
}
/**
* Build a TicketRef list, dedup-merging the same ticket_number across multiple
* sources (highest confidence wins; first-seen source is preserved).
*/
function consolidate(
raw: RawRef[],
meta: Map<string, MetaRow>
): TicketRef[] {
const merged = new Map<string, RawRef>();
for (const r of raw) {
const existing = merged.get(r.ticket_number);
if (!existing) {
merged.set(r.ticket_number, r);
continue;
}
// Promote to high if any source claims high.
if (existing.confidence !== 'high' && r.confidence === 'high') {
merged.set(r.ticket_number, r);
}
}
const out: TicketRef[] = [];
for (const [num, ref] of merged.entries()) {
const m = meta.get(num);
if (!m) continue; // not in our local mirror — drop silently
out.push({
ticket_number: num,
title: m.title,
status_label: m.status_label,
last_activity_date: toIsoOrNull(m.last_activity_date),
source: ref.source,
confidence: ref.confidence,
reason: null,
});
}
return out;
}
export async function discoverExplicitLinks(
bundle: RawTicketBundle
): Promise<{
explicit: TicketRef[];
isProblemTicket: boolean;
problemTicketSignals: string[];
}> {
const raw: RawRef[] = [];
let hasSection = false;
// 1. Description.
if (bundle.ticket.description) {
const r = extractExplicitFromText(
bundle.ticket.description,
'description_mention'
);
raw.push(...r.refs);
if (r.hasRelatedTicketsSection) hasSection = true;
}
// 2. Each retained note (filtered the same way the preprocessor does).
for (const note of bundle.notes) {
if (isWorkflowNoise(note) || isEmailNotification(note)) continue;
if (!note.description) continue;
const r = extractExplicitFromText(note.description, 'note_mention');
raw.push(...r.refs);
if (r.hasRelatedTicketsSection) hasSection = true;
}
// 3. problem_ticket_id column.
if (bundle.ticket.problem_ticket_id !== null) {
const ptn = await resolveProblemTicketNumber(bundle.ticket.problem_ticket_id);
if (ptn) {
raw.push({
ticket_number: ptn,
source: 'problem_ticket_id',
confidence: 'high',
});
}
}
// 4. Drop self-references.
const selfNumber = bundle.ticket.ticket_number;
const filteredRaw = raw.filter((r) => r.ticket_number !== selfNumber);
// 5. Cap and verify against local mirror.
const uniqueNumbers = Array.from(
new Set(filteredRaw.map((r) => r.ticket_number))
).slice(0, MAX_EXPLICIT_LINKS);
const meta = await loadTicketMeta(uniqueNumbers);
const refsInSet = filteredRaw.filter((r) => uniqueNumbers.includes(r.ticket_number));
const explicit = consolidate(refsInSet, meta);
// 6. Sort: high confidence first, then most-recent activity.
explicit.sort((a, b) => {
if (a.confidence !== b.confidence) {
return a.confidence === 'high' ? -1 : 1;
}
const at = a.last_activity_date ?? '';
const bt = b.last_activity_date ?? '';
return bt.localeCompare(at);
});
const ptDetect = detectProblemTicket(bundle, hasSection);
return {
explicit,
isProblemTicket: ptDetect.isProblemTicket,
problemTicketSignals: ptDetect.signals,
};
}
// =============================================================================
// LLM-suggested arm (Haiku) — opt-in.
// =============================================================================
const SuggestedSchema = z.object({
suggestions: z
.array(
z.object({
ticket_number: z.string(),
reason: z.string(),
})
)
.max(MAX_SUGGESTED_LINKS),
});
const SUGGEST_SYSTEM_PROMPT = `You are helping decide which other tickets at this MSP client are likely related to a master ticket the user is investigating.
You will receive:
- The master ticket (number, title, first ~1KB of description).
- A list of recent same-client tickets with their numbers and titles.
Return up to ${MAX_SUGGESTED_LINKS} candidate tickets that look semantically related — same affected systems, users, sites, vendors, symptoms, or recurrence patterns. Skip generic alert tickets that aren't clearly related. Skip tickets that share only the client name.
Respond ONLY with JSON. No prose, no code fences.
Schema:
{ "suggestions": [{ "ticket_number": "T20260430.0084", "reason": "one short sentence" }] }`;
interface CandidateRow {
ticket_number: string;
title: string | null;
status_label: string | null;
last_activity_date: Date | string | null;
create_date: Date | string;
}
async function loadSuggestionCandidates(
bundle: RawTicketBundle,
excludeNumbers: Set<string>
): Promise<CandidateRow[]> {
const res = await postgresClient.query<CandidateRow>(
`SELECT t.ticket_number,
t.title,
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
t.last_activity_date,
t.create_date
FROM tickets t
WHERE t.company_id = $1
AND COALESCE(t.is_deleted, false) = false
AND t.ticket_number <> $2
AND t.create_date >= ($3::timestamp - ($4::int || ' days')::interval)
AND t.create_date <= ($3::timestamp + INTERVAL '1 day')
ORDER BY t.create_date DESC
LIMIT $5`,
[
bundle.ticket.company_id,
bundle.ticket.ticket_number,
bundle.ticket.create_date,
SUGGESTED_CANDIDATE_DAYS,
SUGGESTED_CANDIDATE_LIMIT + excludeNumbers.size,
]
);
return res.rows.filter((r) => !excludeNumbers.has(r.ticket_number)).slice(
0,
SUGGESTED_CANDIDATE_LIMIT
);
}
export async function suggestRelatedLinks(
bundle: RawTicketBundle,
excludeTicketNumbers: string[]
): Promise<TicketRef[]> {
const exclude = new Set(excludeTicketNumbers);
exclude.add(bundle.ticket.ticket_number);
const candidates = await loadSuggestionCandidates(bundle, exclude);
if (candidates.length === 0) return [];
const description = (bundle.ticket.description ?? '').slice(
0,
SUGGESTED_DESCRIPTION_CHAR_CAP
);
const userPayload = [
`=== MASTER TICKET ===`,
JSON.stringify(
{
ticket_number: bundle.ticket.ticket_number,
title: bundle.ticket.title,
description,
},
null,
2
),
``,
`=== CANDIDATE TICKETS (recent same-client, newest first) ===`,
JSON.stringify(
candidates.map((c) => ({
ticket_number: c.ticket_number,
title: c.title,
})),
null,
2
),
].join('\n');
const result = await callLLMStage({
model: HAIKU,
system: SUGGEST_SYSTEM_PROMPT,
user: userPayload,
schema: SuggestedSchema,
maxTokens: SUGGESTED_MAX_TOKENS,
});
const candidateMap = new Map(candidates.map((c) => [c.ticket_number, c]));
const out: TicketRef[] = [];
for (const s of result.data.suggestions) {
const c = candidateMap.get(s.ticket_number);
if (!c) continue; // hallucination guard — model named a non-candidate
if (exclude.has(s.ticket_number)) continue;
out.push({
ticket_number: s.ticket_number,
title: c.title,
status_label: c.status_label,
last_activity_date: toIsoOrNull(c.last_activity_date),
source: 'llm_suggested',
confidence: 'medium',
reason: s.reason,
});
if (out.length >= MAX_SUGGESTED_LINKS) break;
}
return out;
}
export async function discoverLinks(
bundle: RawTicketBundle,
options: { includeSuggested?: boolean } = {}
): Promise<DiscoveredLinks> {
const explicitResult = await discoverExplicitLinks(bundle);
let suggested: TicketRef[] = [];
if (options.includeSuggested) {
const exclude = explicitResult.explicit.map((r) => r.ticket_number);
try {
suggested = await suggestRelatedLinks(bundle, exclude);
} catch (err) {
// Suggestion is opportunistic — never fail the whole call on its
// account. Surface the failure to logs only.
console.warn(
`[ANALYZER-LINKS] suggestion arm failed for ${bundle.ticket.ticket_number}:`,
err instanceof Error ? err.message : err
);
}
}
return {
explicit: explicitResult.explicit,
suggested,
isProblemTicket: explicitResult.isProblemTicket,
problemTicketSignals: explicitResult.problemTicketSignals,
};
}