collabSupport/services/ticketClassifier.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

245 lines
10 KiB
JavaScript

// 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":"<digits>"|null,"reason":"<one sentence, max 200 chars>"}';
/**
* 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) + '…';
}