- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison) - Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis - Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison) - Add veeam-analysis-state.ts and rmm-device-resolver.ts services - Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis - Add backup-status page updates and nav links for new Veeam pages - Add scripts: deactivate-cis-for-inactive-companies, workstation category updates - Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt - Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
149 lines
6.5 KiB
TypeScript
149 lines
6.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
const HUMAN_NOTE_TYPES = [1, 2, 3];
|
|
const MODEL = 'claude-haiku-4-5-20251001';
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = await req.json();
|
|
const { at_ticket_number, hostname, org_name, hours_offline } = body as {
|
|
at_ticket_number: string;
|
|
hostname: string;
|
|
org_name: string;
|
|
hours_offline?: number;
|
|
};
|
|
|
|
if (!at_ticket_number) {
|
|
return NextResponse.json({ error: 'at_ticket_number required' }, { status: 400 });
|
|
}
|
|
|
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
if (!apiKey) {
|
|
return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 });
|
|
}
|
|
|
|
// ── Fetch ticket, notes, time entries ────────────────────────────────────
|
|
const [ticketRes, notesRes, timeRes] = await Promise.all([
|
|
postgresClient.query(`
|
|
SELECT t.id, t.ticket_number, t.title, t.description, t.status,
|
|
t.create_date, t.completed_date,
|
|
c.company_name
|
|
FROM tickets t
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
|
WHERE t.ticket_number = $1
|
|
LIMIT 1
|
|
`, [at_ticket_number]),
|
|
|
|
postgresClient.query(`
|
|
SELECT tn.note_type, tn.title, tn.description, tn.create_date_time,
|
|
r.first_name || ' ' || r.last_name AS author
|
|
FROM ticket_notes tn
|
|
JOIN tickets t ON t.id = tn.ticket_id
|
|
LEFT JOIN resources r ON r.id = tn.creator_resource_id
|
|
WHERE t.ticket_number = $1
|
|
AND tn.note_type = ANY($2)
|
|
AND tn.is_deleted = false
|
|
ORDER BY tn.create_date_time
|
|
`, [at_ticket_number, HUMAN_NOTE_TYPES]),
|
|
|
|
postgresClient.query(`
|
|
SELECT te.hours_worked, te.notes, te.entry_date,
|
|
r.first_name || ' ' || r.last_name AS tech
|
|
FROM time_entries te
|
|
JOIN tickets t ON t.id = te.ticket_id
|
|
LEFT JOIN resources r ON r.id = te.resource_id
|
|
WHERE t.ticket_number = $1
|
|
ORDER BY te.entry_date
|
|
`, [at_ticket_number]),
|
|
]);
|
|
|
|
if (ticketRes.rows.length === 0) {
|
|
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
|
|
}
|
|
|
|
const ticket = ticketRes.rows[0];
|
|
const notes = notesRes.rows;
|
|
const entries = timeRes.rows;
|
|
const totalHours = entries.reduce((s: number, e: any) => s + parseFloat(e.hours_worked ?? 0), 0);
|
|
|
|
// ── Build prompt ─────────────────────────────────────────────────────────
|
|
const offlineContext = hours_offline != null
|
|
? `The device (${hostname}) was last seen by RMM ${Math.round(hours_offline)} hours before this ticket was created, meaning it was offline at the time the backup alert fired.`
|
|
: `We do not have RMM last-seen data for this device.`;
|
|
|
|
const notesText = notes.length > 0
|
|
? notes.map((n: any) =>
|
|
`[${n.author ?? 'Unknown'} - ${new Date(n.create_date_time).toLocaleDateString()}]\n${n.title ? n.title + '\n' : ''}${(n.description ?? '').substring(0, 800)}`
|
|
).join('\n\n---\n\n')
|
|
: '(No human-written notes on this ticket)';
|
|
|
|
const timeText = entries.length > 0
|
|
? entries.map((e: any) =>
|
|
`${e.tech ?? 'Unknown'}: ${e.hours_worked}h — ${(e.notes ?? '').substring(0, 300)}`
|
|
).join('\n')
|
|
: '(No time logged)';
|
|
|
|
const systemPrompt = `You are analyzing Autotask service tickets created by Datto RMM backup monitors to help determine whether an automated RPO-based suppression system would have correctly identified that a ticket was unnecessary.
|
|
|
|
Context: Pulse RPO is a system that monitors Veeam backup jobs. When a workstation (laptop/desktop) has been offline (not seen by RMM) for longer than its backup interval (typically 24 hours), Pulse suppresses the backup alert — because if the machine is offline, it cannot run a backup, so the alert is a false positive requiring no human action.
|
|
|
|
Your task: Analyze the ticket below and determine:
|
|
1. Was any meaningful human work performed that fixed an actual backup problem?
|
|
2. Or was the ticket resolved simply because the machine came back online / the alert self-cleared?
|
|
3. Would suppressing this ticket (never creating it) have been the correct call?
|
|
|
|
Answer concisely in JSON with these fields:
|
|
- "would_suppress_correctly": boolean — true if suppression would have been correct (no real work needed)
|
|
- "confidence": "high" | "medium" | "low"
|
|
- "work_summary": string — 1-2 sentences describing what was actually done (or not done)
|
|
- "reasoning": string — 2-3 sentences explaining why suppression would/would not have been correct
|
|
- "recommendation": string — one actionable sentence`;
|
|
|
|
const userPrompt = `Ticket: ${at_ticket_number}
|
|
Title: ${ticket.title}
|
|
Client: ${ticket.company_name ?? org_name}
|
|
Device: ${hostname}
|
|
Created: ${new Date(ticket.create_date).toLocaleDateString()}
|
|
Completed: ${ticket.completed_date ? new Date(ticket.completed_date).toLocaleDateString() : 'N/A'}
|
|
Total time logged: ${totalHours.toFixed(2)}h
|
|
|
|
Offline status: ${offlineContext}
|
|
|
|
Tech notes on this ticket:
|
|
${notesText}
|
|
|
|
Time entries:
|
|
${timeText}`;
|
|
|
|
// ── Call Anthropic ────────────────────────────────────────────────────────
|
|
const client = new Anthropic({ apiKey });
|
|
const message = await client.messages.create({
|
|
model: MODEL,
|
|
max_tokens: 600,
|
|
system: systemPrompt,
|
|
messages: [{ role: 'user', content: userPrompt }],
|
|
});
|
|
|
|
const content = message.content[0].type === 'text' ? message.content[0].text : '{}';
|
|
// Strip markdown code fences if the model wraps the JSON
|
|
const jsonText = content.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
let analysis: Record<string, any> = {};
|
|
try { analysis = JSON.parse(jsonText); } catch { analysis = { raw: content }; }
|
|
|
|
return NextResponse.json({
|
|
ticket_number: at_ticket_number,
|
|
hostname,
|
|
org_name,
|
|
hours_offline: hours_offline ?? null,
|
|
total_hours_logged: totalHours,
|
|
note_count: notes.length,
|
|
model: MODEL,
|
|
analysis,
|
|
});
|
|
} catch (err: any) {
|
|
console.error('[RPO-ANALYZE] Error:', err.message);
|
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
|
}
|
|
}
|