- 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>
441 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|