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>
This commit is contained in:
parent
ea3471d38d
commit
8f8b5ab7be
53 changed files with 9377 additions and 33 deletions
56
app/analyzer/analysis/[id]/page.tsx
Normal file
56
app/analyzer/analysis/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AnalysisView } from '@/components/analyzer/analysis-view';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
export default function AnalysisDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const [analysis, setAnalysis] = useState<PersistedAnalysis | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/analyzer/analyses/${id}`);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const { analysis } = (await res.json()) as { analysis: PersistedAnalysis };
|
||||
if (!cancelled) setAnalysis(analysis);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load this analysis</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!error && !analysis && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{analysis && <AnalysisView analysis={analysis} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/analyzer/queue/page.tsx
Normal file
127
app/analyzer/queue/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
|
||||
export default function AnalyzerQueuePage() {
|
||||
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/analyzer/needs-review?limit=100`);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] };
|
||||
if (!cancelled) setAnalyses(analyses);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
Needs human review
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI analyses that flagged themselves for human review — high-severity
|
||||
gaps, low confidence, conflicting evidence, or cost-ceiling skips.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load review queue</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Queue
|
||||
{analyses && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{analyses.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyses === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : analyses && analyses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing flagged. 🎉
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{(analyses ?? []).map((a) => (
|
||||
<li key={a.id} className="py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Link
|
||||
href={`/analyzer/analysis/${a.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{a.ticketNumber} · v{a.analysisVersion}
|
||||
</Link>
|
||||
{a.summary && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{a.summary}
|
||||
</p>
|
||||
)}
|
||||
{(a.humanReviewReasons ?? []).length > 0 && (
|
||||
<ul className="text-xs text-muted-foreground list-disc pl-4 mt-1">
|
||||
{(a.humanReviewReasons ?? []).slice(0, 3).map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(a.triggeredAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{a.confidenceScore !== null && (
|
||||
<Badge variant="outline">
|
||||
{Math.round(a.confidenceScore * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
{(a.gaps ?? []).some((g) => g.severity === 'high') && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
high gap
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
app/analyzer/ticket/[ticketNumber]/page.tsx
Normal file
130
app/analyzer/ticket/[ticketNumber]/page.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AnalyzeButton } from '@/components/analyzer/analyze-button';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
|
||||
export default function TicketAnalyzerPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ ticketNumber: string }>;
|
||||
}) {
|
||||
const { ticketNumber } = use(params);
|
||||
const [analyses, setAnalyses] = useState<PersistedAnalysis[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyses`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] };
|
||||
if (!cancelled) setAnalyses(analyses);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ticketNumber]);
|
||||
|
||||
const latest = analyses?.[0];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Ticket</p>
|
||||
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
|
||||
</div>
|
||||
<AnalyzeButton ticketNumber={ticketNumber} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Analyze</strong> to run the AI pipeline. If a current
|
||||
analysis already exists, you’ll be navigated straight to it.
|
||||
Otherwise the run takes ~10–60 seconds.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load analysis history</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Analysis history</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyses === null && !error ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : analyses && analyses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No analyses yet. Run one above.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{(analyses ?? []).map((a) => (
|
||||
<li key={a.id} className="py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/analyzer/analysis/${a.id}`}
|
||||
className="font-medium hover:underline flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Version {a.analysisVersion}
|
||||
{latest?.id === a.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
latest
|
||||
</Badge>
|
||||
)}
|
||||
{a.needsHumanReview && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Needs review
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(a.triggeredAt).toLocaleString()}
|
||||
{' · '}
|
||||
{a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'}
|
||||
{' · '}${a.estimatedCostUsd.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
{a.confidenceScore !== null && (
|
||||
<Badge variant="outline">
|
||||
{Math.round(a.confidenceScore * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/api/analyzer/analyses/[id]/route.ts
Normal file
25
app/api/analyzer/analyses/[id]/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* GET /api/analyzer/analyses/:id
|
||||
*
|
||||
* Fetch a specific analysis by id, including the full timeline / gaps /
|
||||
* IT Glue references. Returns 404 if not found.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { getAnalysisById } from '@/lib/services/analyzer/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
const analysis = await getAnalysisById(id);
|
||||
if (!analysis) {
|
||||
return NextResponse.json({ error: 'Analysis not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ analysis });
|
||||
}
|
||||
91
app/api/analyzer/analyses/[id]/share/route.ts
Normal file
91
app/api/analyzer/analyses/[id]/share/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* POST /api/analyzer/analyses/:id/share
|
||||
*
|
||||
* Body: { recipientEmail: string, note?: string }
|
||||
*
|
||||
* Records a share row in analyzer_shares. Email send is deferred to phase 8
|
||||
* — this endpoint validates the recipient domain against ALLOWED_SHARE_DOMAINS
|
||||
* and persists the audit row. The share is "pending delivery" until phase 8
|
||||
* wires up nodemailer.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { ShareAnalysisRequest } from '@/lib/types/analyzer';
|
||||
import {
|
||||
createShare,
|
||||
getAnalysisById,
|
||||
} from '@/lib/services/analyzer/persistence';
|
||||
|
||||
function getAllowedDomains(): string[] {
|
||||
const raw = process.env.ALLOWED_SHARE_DOMAINS ?? '';
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter((d) => d.length > 0);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const result = ShareAnalysisRequest.safeParse(body);
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: result.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { recipientEmail, note } = result.data;
|
||||
|
||||
const allowedDomains = getAllowedDomains();
|
||||
if (allowedDomains.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Sharing is not configured',
|
||||
message: 'ALLOWED_SHARE_DOMAINS env var is empty.',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const domain = recipientEmail.split('@')[1]?.toLowerCase();
|
||||
if (!domain || !allowedDomains.includes(domain)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Recipient domain is not allowed',
|
||||
message: `Allowed domains: ${allowedDomains.join(', ')}`,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm the analysis exists.
|
||||
const analysis = await getAnalysisById(id);
|
||||
if (!analysis) {
|
||||
return NextResponse.json({ error: 'Analysis not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userId = (session?.user as { id: string }).id;
|
||||
const share = await createShare({
|
||||
analysis_id: id,
|
||||
shared_by_user_id: userId,
|
||||
shared_with_email: recipientEmail,
|
||||
note,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
share: {
|
||||
id: share.id,
|
||||
analysisId: id,
|
||||
sharedWithEmail: recipientEmail,
|
||||
sharedAt: share.shared_at,
|
||||
note: note ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
25
app/api/analyzer/jobs/[jobId]/route.ts
Normal file
25
app/api/analyzer/jobs/[jobId]/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* GET /api/analyzer/jobs/:jobId
|
||||
*
|
||||
* Returns the current status of an analyzer job. Frontend polls this every
|
||||
* ~2 seconds during a run.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { getJob } from '@/lib/services/analyzer/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { jobId } = await params;
|
||||
const job = await getJob(jobId);
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ job });
|
||||
}
|
||||
25
app/api/analyzer/needs-review/route.ts
Normal file
25
app/api/analyzer/needs-review/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* GET /api/analyzer/needs-review
|
||||
*
|
||||
* Returns the queue of completed analyses where needs_human_review = true.
|
||||
* Optional query params: limit (default 50, max 200), offset (default 0).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listNeedsReview } from '@/lib/services/analyzer/persistence';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const limit = parseInt(params.get('limit') ?? '50', 10);
|
||||
const offset = parseInt(params.get('offset') ?? '0', 10);
|
||||
|
||||
const analyses = await listNeedsReview({
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
offset: Number.isFinite(offset) ? offset : 0,
|
||||
});
|
||||
return NextResponse.json({ analyses });
|
||||
}
|
||||
21
app/api/analyzer/tickets/[ticketNumber]/analyses/route.ts
Normal file
21
app/api/analyzer/tickets/[ticketNumber]/analyses/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* GET /api/analyzer/tickets/:ticketNumber/analyses
|
||||
*
|
||||
* Lists all analysis versions for a ticket, newest first.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { listAnalysesByTicketNumber } from '@/lib/services/analyzer/persistence';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||||
) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const { ticketNumber } = await params;
|
||||
const analyses = await listAnalysesByTicketNumber(ticketNumber);
|
||||
return NextResponse.json({ analyses });
|
||||
}
|
||||
101
app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts
Normal file
101
app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue