// src/services/ticketClassifier.js // // AI-based classifier for Jira poller. Reads a single Jira issue and // returns a strict `{ kind, storeNum, reason, tokensUsed }` shape. // // Design decisions (locked with the operator up front): // - Full-auto: whatever the model returns is what runs. No confidence // gate. The Webex summary post exposes the model's `reason` per // ticket so a human can spot-check bad classifications. // - Skip-until-recovery: any failure (network, timeout, malformed // JSON, missing required field) throws `TicketClassifierError`. // The poller's per-ticket try/catch catches it, logs, and skips // without labeling — that ticket comes back next hour. // - Closed output taxonomy: kind ∈ {'phone', 'av', 'skip'}. Not // 'unknown', not 'other'. Every ticket falls into one bucket, and // 'skip' is a valid, safe non-action. // - JSON mode (`response_format: json_object`) is on so we don't fight // markdown-wrapped output. Fallback: `parseAndValidate` still // tolerates fenced code blocks if the model ignores the hint. // // Store Number extraction: because your Jira's Store Number custom // field is an opaque Atlassian Assets object reference (see comment on // the poller's `extractStore` for context), the AI is the ONLY reliable // way to get a numeric store id. The prompt tells the model to look at // summary + description, where techs consistently type "Store 3860". import { callGrok } from '../utils/grokClient.js'; import { logger } from '../utils/logger.js'; const MAX_DESCRIPTION_CHARS = 2000; const MAX_REASON_CHARS = 200; const VALID_KINDS = new Set(['phone', 'av', 'skip']); // Optional per-call model override. When unset, defers to XAI_MODEL // via callGrok. Classification is a small structured task that runs // well on a cheaper/faster model than long-form summaries. const CLASSIFIER_MODEL = process.env.JIRA_POLLER_MODEL || undefined; const SYSTEM_PROMPT = "You are triaging unassigned IT support tickets at American Eagle Outfitters (retail stores + corporate).\n" + "\n" + "Classify the ticket into EXACTLY one category:\n" + '- "phone": SIP desk phones, DECT wireless handsets/basestations, extension routing,\n' + " voicemail, dial tone, call quality, telephony provisioning, Webex Calling on desk\n" + " phones. NOT general Webex account access.\n" + '- "av": Cisco Webex Room devices, cameras, microphones, digital signage,\n' + " audio amplifiers, in-store music playback, conference-room A/V technology.\n" + '- "skip": anything else — laptops, mobile devices, printers, iPhones/iPads,\n' + " account/access requests, Webex account requests (unrelated to phones),\n" + " general networking, hardware orders, non-technical requests, or store\n" + " requests we don't have a matching tool for yet.\n" + "\n" + "Extract a store number ONLY if the ticket is store-scoped. Store numbers are\n" + "2-6 digit integers and usually appear in the summary as 'Store NNNN - ...' but\n" + "may also be in the description or the 'Store Number' field. If the ticket is\n" + "not clearly for a specific retail store, return null.\n" + "\n" + "Respond with a SINGLE JSON object matching this schema EXACTLY (no markdown,\n" + "no code fences, no extra keys, no prose before or after):\n" + '{"kind":"phone"|"av"|"skip","storeNum":""|null,"reason":""}'; /** * Custom error type so the poller's catch block can tell a classifier * failure apart from a `collectPhoneStatus` / `collectDeviceStatus` * failure — both should skip the ticket, but they have different * operator-facing meanings in the log. */ export class TicketClassifierError extends Error { constructor(message, cause) { super(message); this.name = 'TicketClassifierError'; if (cause) this.cause = cause; } } /** * Build the per-ticket user message. Kept as a pure helper so it can * be unit-tested without a live X.AI call. * * The summary is intentionally NOT truncated — store numbers almost * always live in the title and truncating there would defeat the whole * point. Description gets a 2000-char cap to keep the payload cheap. * * The raw Store Number field value is included even though it's usually * an opaque Assets reference — sometimes it IS a plain string on other * tenants / older tickets, and giving the model a chance to use it * doesn't cost anything. */ export function buildUserMessage(ticket) { const key = ticket?.key || '(unknown)'; const summary = ticket?.summary || '(no summary)'; const description = truncate(ticket?.description || '', MAX_DESCRIPTION_CHARS); const components = Array.isArray(ticket?.components) ? ticket.components.map((c) => c?.name).filter(Boolean).join(', ') : ''; const status = ticket?.status || ''; const reporter = ticket?.reporter || ''; const storeFieldRaw = ticket?.storeFieldRaw; let storeFieldDisplay = '(empty)'; if (storeFieldRaw !== null && storeFieldRaw !== undefined && storeFieldRaw !== '') { if (typeof storeFieldRaw === 'object') { // Compact JSON so the model isn't drowning in whitespace but still // sees whatever primitive fell out of the Jira response. storeFieldDisplay = JSON.stringify(storeFieldRaw); } else { storeFieldDisplay = String(storeFieldRaw); } } return ( `Ticket: ${key}\n` + `Component(s): ${components || '(none)'}\n` + `Status: ${status || '(none)'}\n` + `Store Number field: ${storeFieldDisplay}\n` + `Reporter: ${reporter || '(unknown)'}\n` + `\n` + `Summary:\n${summary}\n` + `\n` + `Description:\n${description || '(none)'}` ); } /** * Parse and validate the model's response. Handles: * - naked JSON object (JSON-mode happy path) * - JSON object wrapped in ```json ... ``` fences (model ignoring hint) * - leading/trailing prose * * Throws `TicketClassifierError` on any shape violation. */ export function parseAndValidate(rawContent) { if (typeof rawContent !== 'string' || rawContent.trim() === '') { throw new TicketClassifierError('empty response from model'); } // Strip optional markdown code fences the model might sneak in. const cleaned = rawContent .trim() .replace(/^```(?:json)?\s*/i, '') .replace(/```\s*$/i, '') .trim(); // Extract the first {...} block if there's stray prose around it. const firstBrace = cleaned.indexOf('{'); const lastBrace = cleaned.lastIndexOf('}'); if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) { throw new TicketClassifierError(`no JSON object found in response: ${cleaned.slice(0, 120)}`); } const jsonSlice = cleaned.slice(firstBrace, lastBrace + 1); let obj; try { obj = JSON.parse(jsonSlice); } catch (err) { throw new TicketClassifierError(`JSON.parse failed: ${err.message} (raw: ${jsonSlice.slice(0, 120)})`); } if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { throw new TicketClassifierError(`response is not a JSON object (got ${Array.isArray(obj) ? 'array' : typeof obj})`); } if (!VALID_KINDS.has(obj.kind)) { throw new TicketClassifierError(`invalid or missing 'kind' (got ${JSON.stringify(obj.kind)}, expected phone|av|skip)`); } let storeNum = obj.storeNum; if (storeNum === undefined) { throw new TicketClassifierError("missing 'storeNum' field (must be a string of digits or null)"); } if (storeNum !== null) { // Accept numbers too — some models can't help themselves and // return `"storeNum": 3860` instead of `"3860"`. const asString = String(storeNum).trim(); if (!/^\d{2,6}$/.test(asString)) { throw new TicketClassifierError(`invalid 'storeNum' (got ${JSON.stringify(storeNum)}, expected 2-6 digit string or null)`); } storeNum = asString; } const reason = typeof obj.reason === 'string' ? obj.reason.trim() : ''; if (!reason) { throw new TicketClassifierError("missing or empty 'reason' string"); } const truncatedReason = reason.length > MAX_REASON_CHARS ? reason.slice(0, MAX_REASON_CHARS - 1) + '…' : reason; return { kind: obj.kind, storeNum, reason: truncatedReason }; } /** * Classify a single Jira ticket via X.AI. * * @param {object} ticket * @param {string} ticket.key * @param {string} ticket.summary * @param {string} [ticket.description] * @param {Array<{name: string}>} [ticket.components] * @param {string} [ticket.status] * @param {string} [ticket.reporter] * @param {*} [ticket.storeFieldRaw] Raw value of the Store Number custom field. * * @returns {Promise<{ kind: 'phone'|'av'|'skip', storeNum: string|null, reason: string, tokensUsed: number }>} * @throws {TicketClassifierError} on any failure — network, timeout, malformed JSON, invalid shape. */ export async function classifyTicket(ticket) { const userMessage = buildUserMessage(ticket); let response; try { response = await callGrok(userMessage, { system: SYSTEM_PROMPT, response_format: { type: 'json_object' }, model: CLASSIFIER_MODEL, // Classification wants determinism, not creativity. temperature: 0.0, // JSON payloads for our schema are ~50 tokens — 200 is plenty of headroom. max_tokens: 200, // Slightly tighter than the default 15s: classification is a small // structured task, and long hangs starve the hourly cron. timeout: 10000, includeUsage: true, }); } catch (err) { throw new TicketClassifierError(`X.AI call failed: ${err.message}`, err); } const parsed = parseAndValidate(response?.content || ''); const tokensUsed = response?.usage?.total_tokens || 0; logger( 'ticket:classifier', `${ticket?.key || '?'}: kind=${parsed.kind} store=${parsed.storeNum || 'null'} tokens=${tokensUsed}`, 'debug' ); return { ...parsed, tokensUsed }; } // Private helper — string truncation with an ellipsis. Also exported so // smoke tests can exercise it if we ever add stronger edge-case cover. function truncate(s, max) { if (typeof s !== 'string') return ''; if (s.length <= max) return s; return s.slice(0, max - 1) + '…'; }