wulf-pulse/lib/services/ai-triage-service.ts

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();