wulf-pulse/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts
lorentz 8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:59:40 -04:00

101 lines
2.9 KiB
TypeScript

/**
* POST /api/analyzer/tickets/:ticketNumber/analyze
*
* Body: { force?: boolean }
* Response on existing match (force=false):
* { status: "complete", existingAnalysisId, existingAnalysisVersion }
* Response when queued:
* { status: "queued", jobId }
*
* The worker (lib/services/analyzer/worker.ts) picks up queued jobs and runs
* the full pipeline. Frontend should poll GET /api/analyzer/jobs/:jobId.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { AnalyzeTicketRequest } from '@/lib/types/analyzer';
import {
loadTicketBundle,
TicketNotFoundError,
} from '@/lib/services/analyzer/data-access';
import { preprocessTicket } from '@/lib/services/analyzer/preprocessor';
import {
findExistingAnalysisByContentHash,
queueJob,
} from '@/lib/services/analyzer/persistence';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ ticketNumber: string }> }
) {
const { session, error } = await requireAuth();
if (error) return error;
const { ticketNumber } = await params;
let parsedBody: { force?: boolean };
try {
const body = await request.json().catch(() => ({}));
const result = AnalyzeTicketRequest.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid request body', details: result.error.issues },
{ status: 400 }
);
}
parsedBody = result.data;
} catch {
parsedBody = {};
}
// Verify the ticket exists and load its data for the idempotency check.
let bundle;
try {
bundle = await loadTicketBundle(ticketNumber);
} catch (err) {
if (err instanceof TicketNotFoundError) {
return NextResponse.json(
{ error: `Ticket ${ticketNumber} not found in local mirror` },
{ status: 404 }
);
}
console.error('[analyze] data-access error:', err);
return NextResponse.json(
{
error: 'Failed to load ticket',
message: err instanceof Error ? err.message : 'unknown',
},
{ status: 500 }
);
}
// Idempotency short-circuit: when force=false, return the existing analysis
// without queueing a job if the source data hasn't changed since.
if (!parsedBody.force) {
const pre = preprocessTicket(bundle);
const existing = await findExistingAnalysisByContentHash(
ticketNumber,
pre.content_hash
);
if (existing) {
return NextResponse.json({
status: 'complete',
existingAnalysisId: existing.id,
existingAnalysisVersion: existing.analysis_version,
});
}
}
// Queue a new job. The worker self-init at server-start (production) will
// pick it up; in dev set ANALYZER_WORKER_AUTOSTART=1.
const userId = (session?.user as { id: string }).id;
const job = await queueJob({
ticket_number: ticketNumber,
queued_by_user_id: userId,
});
return NextResponse.json({
status: 'queued',
jobId: job.id,
});
}