359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
/**
|
|
* AI Triage Service
|
|
* Handles AI-powered classification for ambiguous tickets and text enhancement.
|
|
* Only called when robotic classifier can't confidently classify, or when
|
|
* title/description cleanup is needed.
|
|
*/
|
|
|
|
import { postgresClient } from './postgres-client';
|
|
import {
|
|
AiEnhancementResult,
|
|
AiPromptTemplate,
|
|
TicketData,
|
|
WorkflowSettings,
|
|
ClassificationResult,
|
|
PromptPurpose,
|
|
} from '../types/workflow';
|
|
|
|
export class AiTriageService {
|
|
/**
|
|
* Call the configured AI provider with a prompt.
|
|
*/
|
|
private async callProvider(
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
settings: WorkflowSettings,
|
|
templateOverrides?: { provider?: string; model?: string; temperature?: number; max_tokens?: number }
|
|
): Promise<string> {
|
|
const provider = templateOverrides?.provider || settings.default_ai_provider;
|
|
const temperature = templateOverrides?.temperature ?? 0.3;
|
|
const maxTokens = templateOverrides?.max_tokens ?? 4000;
|
|
|
|
if (provider === 'anthropic') {
|
|
return this.callAnthropic(
|
|
systemPrompt,
|
|
userPrompt,
|
|
templateOverrides?.model || settings.anthropic_model,
|
|
settings.anthropic_api_key,
|
|
temperature,
|
|
maxTokens
|
|
);
|
|
} else {
|
|
return this.callOpenAI(
|
|
systemPrompt,
|
|
userPrompt,
|
|
templateOverrides?.model || settings.openai_model,
|
|
settings.openai_api_key,
|
|
temperature,
|
|
maxTokens
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call OpenAI API.
|
|
*/
|
|
private async callOpenAI(
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
model: string,
|
|
apiKey: string,
|
|
temperature: number,
|
|
maxTokens: number
|
|
): Promise<string> {
|
|
if (!apiKey) throw new Error('OpenAI API key not configured');
|
|
|
|
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
temperature,
|
|
max_tokens: maxTokens,
|
|
response_format: { type: 'json_object' },
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`OpenAI API error (${response.status}): ${error}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.choices[0]?.message?.content || '';
|
|
}
|
|
|
|
/**
|
|
* Call Anthropic API.
|
|
*/
|
|
private async callAnthropic(
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
model: string,
|
|
apiKey: string,
|
|
temperature: number,
|
|
maxTokens: number
|
|
): Promise<string> {
|
|
if (!apiKey) throw new Error('Anthropic API key not configured');
|
|
|
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
max_tokens: maxTokens,
|
|
temperature,
|
|
system: systemPrompt,
|
|
messages: [{ role: 'user', content: userPrompt }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const textBlock = data.content?.find((b: any) => b.type === 'text');
|
|
return textBlock?.text || '';
|
|
}
|
|
|
|
/**
|
|
* Load an active AI prompt template by purpose.
|
|
*/
|
|
async getTemplate(purpose: PromptPurpose): Promise<AiPromptTemplate | null> {
|
|
const result = await postgresClient.query<AiPromptTemplate>(
|
|
`SELECT * FROM ai_prompt_templates WHERE purpose = $1 AND is_active = true ORDER BY version DESC LIMIT 1`,
|
|
[purpose]
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Interpolate template variables in a prompt string.
|
|
* Supports {{field}} syntax.
|
|
*/
|
|
private interpolate(template: string, vars: Record<string, any>): string {
|
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
const val = vars[key];
|
|
return val !== undefined && val !== null ? String(val) : '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Classify ambiguous fields that the robotic classifier couldn't handle.
|
|
* Returns only the fields that AI classified.
|
|
*/
|
|
async classifyAmbiguous(
|
|
ticket: TicketData,
|
|
failedFields: string[],
|
|
settings: WorkflowSettings
|
|
): Promise<AiEnhancementResult> {
|
|
const template = await this.getTemplate('ambiguous_classification');
|
|
|
|
// Load picklist data for AI context
|
|
const [issueTypes, subIssueTypes, priorities] = await Promise.all([
|
|
postgresClient.query(`SELECT value, label FROM issue_types WHERE is_deleted = false ORDER BY label`),
|
|
postgresClient.query(`SELECT value, label, parent_value FROM sub_issue_types WHERE is_deleted = false ORDER BY label`),
|
|
postgresClient.query(`SELECT value, label FROM priorities WHERE is_active = true ORDER BY value`),
|
|
]);
|
|
|
|
const picklistContext = {
|
|
issueTypes: JSON.stringify(issueTypes.rows),
|
|
subIssueTypes: JSON.stringify(subIssueTypes.rows),
|
|
priorities: JSON.stringify(priorities.rows),
|
|
};
|
|
|
|
const systemPrompt = template?.system_prompt || this.getDefaultClassificationSystemPrompt();
|
|
const userTemplate = template?.user_prompt_template || this.getDefaultClassificationUserPrompt();
|
|
|
|
const userPrompt = this.interpolate(userTemplate, {
|
|
title: ticket.title,
|
|
description: ticket.description || 'No description provided',
|
|
ticket_category: ticket.ticket_category,
|
|
failed_fields: failedFields.join(', '),
|
|
...picklistContext,
|
|
});
|
|
|
|
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
|
provider: template?.provider,
|
|
model: template?.model,
|
|
temperature: template?.temperature,
|
|
max_tokens: template?.max_tokens,
|
|
});
|
|
|
|
try {
|
|
const parsed = JSON.parse(aiResponse);
|
|
return {
|
|
classification: {
|
|
issue_type: parsed.issueType || parsed.issue_type,
|
|
sub_issue_type: parsed.subIssueType || parsed.sub_issue_type,
|
|
ticket_type: parsed.ticketType || parsed.ticket_type,
|
|
priority: parsed.priority,
|
|
},
|
|
method: 'ai',
|
|
};
|
|
} catch {
|
|
console.error('[AI-TRIAGE] Failed to parse AI classification response:', aiResponse);
|
|
return { method: 'ai' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up a messy ticket title (email subject, too long, garbled).
|
|
*/
|
|
async cleanupTitle(
|
|
ticket: TicketData,
|
|
settings: WorkflowSettings
|
|
): Promise<string | undefined> {
|
|
if (!settings.ai_for_title_cleanup) return undefined;
|
|
|
|
const template = await this.getTemplate('title_cleanup');
|
|
const systemPrompt = template?.system_prompt ||
|
|
'You are a helpdesk ticket title cleaner. Given a ticket title, produce a clean, concise title (max 80 chars). Remove email prefixes (Re:, Fw:, Fwd:), ticket numbers, excessive punctuation, and redundant info. Return JSON: {"title": "cleaned title"}';
|
|
|
|
const userTemplate = template?.user_prompt_template ||
|
|
'Clean up this ticket title:\n\nOriginal title: {{title}}\nDescription (for context): {{description}}';
|
|
|
|
const userPrompt = this.interpolate(userTemplate, {
|
|
title: ticket.title,
|
|
description: (ticket.description || '').substring(0, 500),
|
|
});
|
|
|
|
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
|
provider: template?.provider,
|
|
model: template?.model,
|
|
temperature: template?.temperature ?? 0.2,
|
|
max_tokens: template?.max_tokens ?? 200,
|
|
});
|
|
|
|
try {
|
|
const parsed = JSON.parse(aiResponse);
|
|
return parsed.title || undefined;
|
|
} catch {
|
|
console.error('[AI-TRIAGE] Failed to parse title cleanup response:', aiResponse);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rewrite/restructure a ticket description.
|
|
*/
|
|
async rewriteDescription(
|
|
ticket: TicketData,
|
|
branch: string,
|
|
settings: WorkflowSettings
|
|
): Promise<string | undefined> {
|
|
if (!settings.ai_for_description_rewrite) return undefined;
|
|
if (!ticket.description || ticket.description.length < 50) return undefined;
|
|
|
|
const purpose: PromptPurpose = branch === 'noc' ? 'noc_format' : 'description_rewrite';
|
|
const template = await this.getTemplate(purpose);
|
|
if (!template) return undefined;
|
|
|
|
const userPrompt = this.interpolate(template.user_prompt_template, {
|
|
title: ticket.title,
|
|
description: ticket.description,
|
|
branch,
|
|
});
|
|
|
|
const aiResponse = await this.callProvider(template.system_prompt, userPrompt, settings, {
|
|
provider: template.provider,
|
|
model: template.model,
|
|
temperature: template.temperature,
|
|
max_tokens: template.max_tokens,
|
|
});
|
|
|
|
try {
|
|
const parsed = JSON.parse(aiResponse);
|
|
return parsed.description || undefined;
|
|
} catch {
|
|
// If not JSON, return raw text as description
|
|
return aiResponse.trim() || undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate troubleshooting steps for an incident ticket.
|
|
*/
|
|
async generateTroubleshootingSteps(
|
|
ticket: TicketData,
|
|
settings: WorkflowSettings
|
|
): Promise<string | undefined> {
|
|
if (!settings.ai_for_troubleshooting) return undefined;
|
|
|
|
const template = await this.getTemplate('troubleshooting_steps');
|
|
const systemPrompt = template?.system_prompt ||
|
|
'You are an IT helpdesk assistant. Given a support ticket, generate 3-5 concise troubleshooting steps. Return JSON: {"steps": "numbered list of steps"}';
|
|
|
|
const userTemplate = template?.user_prompt_template ||
|
|
'Generate troubleshooting steps for this ticket:\n\nTitle: {{title}}\nDescription: {{description}}';
|
|
|
|
const userPrompt = this.interpolate(userTemplate, {
|
|
title: ticket.title,
|
|
description: (ticket.description || '').substring(0, 2000),
|
|
});
|
|
|
|
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
|
provider: template?.provider,
|
|
model: template?.model,
|
|
temperature: template?.temperature ?? 0.3,
|
|
max_tokens: template?.max_tokens ?? 1000,
|
|
});
|
|
|
|
try {
|
|
const parsed = JSON.parse(aiResponse);
|
|
return parsed.steps || undefined;
|
|
} catch {
|
|
return aiResponse.trim() || undefined;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Default prompts (used when no template is configured in DB)
|
|
// ============================================================================
|
|
|
|
private getDefaultClassificationSystemPrompt(): string {
|
|
return `You are an IT helpdesk ticket classifier. Given a ticket title and description, classify the ticket into the correct issue type, sub-issue type, ticket type, and priority.
|
|
|
|
Rules:
|
|
- ticket_type: 1 = Service Request, 2 = Incident
|
|
- Use the provided picklist data to select valid issue types and sub-issue types
|
|
- Ensure sub_issue_type parent_value matches the issue_type value
|
|
- Return ONLY valid picklist values
|
|
|
|
Return JSON format:
|
|
{"issueType": <number>, "subIssueType": <number>, "ticketType": <number>, "priority": <number>}`;
|
|
}
|
|
|
|
private getDefaultClassificationUserPrompt(): string {
|
|
return `Classify this ticket. Only provide values for these fields that need classification: {{failed_fields}}
|
|
|
|
Title: {{title}}
|
|
Description: {{description}}
|
|
Ticket Category: {{ticket_category}}
|
|
|
|
Available Issue Types:
|
|
{{issueTypes}}
|
|
|
|
Available Sub-Issue Types (with parent_value):
|
|
{{subIssueTypes}}
|
|
|
|
Available Priorities:
|
|
{{priorities}}`;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const aiTriageService = new AiTriageService();
|