wulf-pulse/app/api/veeam/ticket-analysis/summary/route.ts
lorentz ea3471d38d feat: Veeam RPO analysis, comparison, ticket analysis + company teams table
- 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
2026-04-29 09:16:46 -04:00

185 lines
8.3 KiB
TypeScript

import { NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import postgresClient from '@/lib/services/postgres-client';
const MODEL = 'claude-sonnet-4-6';
const SYSTEM = 'You are a senior MSP consultant. Respond with valid JSON only — no markdown fences, no prose before or after the JSON object.';
export async function POST() {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 });
}
try {
const [statsRes, categoryRes, resolutionRes, skillsRes, complexityRes, proceduresRes] =
await Promise.all([
postgresClient.query(`
SELECT
COUNT(*) AS total_analyzed,
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE backup_completed_before_tech = true)
/ NULLIF(COUNT(*) FILTER (WHERE backup_completed_before_tech IS NOT NULL), 0), 1) AS auto_resolved_pct,
SUM(hours_worked) AS total_hours
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
`),
postgresClient.query(`
SELECT problem_category,
COUNT(*) AS count,
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY problem_category
ORDER BY count DESC
`),
postgresClient.query(`
SELECT resolution_type, COUNT(*) AS count
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY resolution_type ORDER BY count DESC
`),
postgresClient.query(`
SELECT skill, COUNT(*) AS count
FROM veeam_ticket_analysis, unnest(skills_required) AS skill
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY skill ORDER BY count DESC LIMIT 20
`),
postgresClient.query(`
SELECT complexity, COUNT(*) AS count
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY complexity
ORDER BY CASE complexity WHEN 'trivial' THEN 1 WHEN 'low' THEN 2 WHEN 'medium' THEN 3 WHEN 'high' THEN 4 ELSE 5 END
`),
// Sample recommended procedures per category (up to 3 per category)
postgresClient.query(`
SELECT problem_category, recommended_procedure
FROM (
SELECT problem_category, recommended_procedure,
ROW_NUMBER() OVER (PARTITION BY problem_category ORDER BY analyzed_at DESC) AS rn
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
AND recommended_procedure IS NOT NULL AND recommended_procedure != ''
) ranked
WHERE rn <= 3
ORDER BY problem_category, rn
`),
]);
const stats = statsRes.rows[0];
const categories = categoryRes.rows;
const resolutions = resolutionRes.rows;
const skills = skillsRes.rows;
const complexity = complexityRes.rows;
const procedures = proceduresRes.rows;
if (parseInt(stats.total_analyzed) === 0) {
return NextResponse.json({ error: 'No analyzed tickets yet — run the analysis first.' }, { status: 400 });
}
const procByCategory: Record<string, string[]> = {};
for (const p of procedures) {
if (!procByCategory[p.problem_category]) procByCategory[p.problem_category] = [];
procByCategory[p.problem_category].push(p.recommended_procedure);
}
const prompt = `Review this YTD backup ticket data for a managed services provider and produce a practical operations summary.
## Dataset
- Tickets analyzed: ${stats.total_analyzed} (YTD, all had tech time logged)
- Total tech time: ${parseFloat(stats.total_hours ?? 0).toFixed(1)}h
- Avg hours per ticket: ${stats.avg_hours}h
- Same-day close rate: ${stats.same_day_pct}%
- Device was offline at alert time: ${stats.offline_pct}%
- Backup auto-completed before tech action: ${stats.auto_resolved_pct}%
- Assessed as preventable: ${stats.preventable_pct}%
## Problem Categories
${categories.map(c => `- ${c.problem_category}: ${c.count} tickets, avg ${c.avg_hours}h, ${c.same_day_pct}% same-day close, ${c.offline_pct ?? 0}% offline at time`).join('\n')}
## Resolution Types
${resolutions.map(r => `- ${r.resolution_type}: ${r.count} tickets`).join('\n')}
## Complexity Breakdown
${complexity.map(c => `- ${c.complexity}: ${c.count} tickets`).join('\n')}
## Top Skills Required (by frequency)
${skills.map(s => `- ${s.skill}: ${s.count} tickets`).join('\n')}
## Sample AI-Generated SOP Steps (per category)
${Object.entries(procByCategory).map(([cat, procs]) =>
`${cat}:\n${procs.map(p => `${p}`).join('\n')}`
).join('\n')}
---
Return a JSON object with exactly this structure:
{
"headline": "one sentence executive summary",
"key_findings": ["3-4 bullet point findings with specific numbers"],
"issue_breakdown": [
{
"category": "exact machine key from Problem Categories above (e.g. device_offline, agent_issue)",
"insight": "1-2 sentence insight about this category",
"sop": "concrete, actionable SOP recommendation for handling this type"
}
],
"skills_assessment": "2-3 sentences on the skills picture — what's needed most, any gaps",
"quick_wins": ["2-3 specific things the MSP could do to reduce ticket volume or time spent"],
"automation_opportunities": "1-2 sentences on what could be automated or suppressed",
"training_priority": "one sentence on the highest-value training investment"
}
Be specific and direct. Reference actual numbers from the data. Avoid generic MSP advice.`;
const client = new Anthropic({ apiKey });
const message = await client.messages.create({
model: MODEL,
max_tokens: 4000,
system: SYSTEM,
messages: [{ role: 'user', content: prompt }],
});
if (message.stop_reason === 'max_tokens') {
console.error('[VEEAM-SUMMARY] Response truncated at max_tokens');
return NextResponse.json({ error: 'Model response was truncated — try again' }, { status: 502 });
}
const raw = message.content[0].type === 'text' ? message.content[0].text : '{}';
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
const json = start !== -1 && end > start ? raw.slice(start, end + 1) : raw;
let analysis: Record<string, any> = {};
try {
analysis = JSON.parse(json);
} catch (e) {
console.error('[VEEAM-SUMMARY] JSON parse failed. Raw response:', raw);
return NextResponse.json({ error: 'Model returned non-JSON response', raw }, { status: 502 });
}
return NextResponse.json({ analysis, model: MODEL, generated_at: new Date().toISOString() });
} catch (e: any) {
console.error('[VEEAM-SUMMARY] Unexpected error:', e);
return NextResponse.json({ error: e.message ?? 'Internal server error' }, { status: 500 });
}
}