Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:
2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
or failure. Worker persists a status='failed' analyzer_analyses row when
the pipeline throws so partial stage records have a parent. Pipeline
exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
queue/status/priority/assignee, sticky filter bar, active-filter chips,
bulk selection persisted via localStorage, "Analyze N selected" +
"Generate aggregate report" actions. New <MultiSelect> primitive.
Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
SQL distributions immediately so UI shows partial state during the
Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
/:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
$20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
override. Every gating decision audited.
2.8 Runbook + build notes updated.
128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
6.6 KiB
TypeScript
210 lines
6.6 KiB
TypeScript
/**
|
|
* POST /api/analyzer/aggregate-reports
|
|
* GET /api/analyzer/aggregate-reports
|
|
*
|
|
* POST: queue a new aggregate report. Validates inputs, creates a 'pending'
|
|
* row, fires runAggregateReport in the background, returns immediately with
|
|
* the report id.
|
|
*
|
|
* GET: list reports (paginated, optionally filtered by user).
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
import { requireAuth } from '@/lib/auth-utils';
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
import {
|
|
createAggregateReport,
|
|
listAggregateReports,
|
|
runAggregateReport,
|
|
} from '@/lib/services/analyzer/aggregate-persistence';
|
|
import {
|
|
estimateAggregateReportCost,
|
|
evaluateCost,
|
|
recordCostAuditDecision,
|
|
} from '@/lib/services/analyzer/cost-guard';
|
|
|
|
const MAX_TICKETS_PER_REPORT = 100;
|
|
|
|
const PostBody = z.object({
|
|
analysisIds: z.array(z.string().uuid()).max(MAX_TICKETS_PER_REPORT).optional(),
|
|
ticketNumbers: z.array(z.string()).max(MAX_TICKETS_PER_REPORT).optional(),
|
|
includeItglueContext: z.boolean().default(true),
|
|
reportTitle: z.string().max(200).nullable().optional(),
|
|
/** Acknowledges the per-request cost guard ($5 threshold). */
|
|
confirmedCost: z.boolean().default(false),
|
|
});
|
|
|
|
interface FingerprintCheckRow {
|
|
id: string;
|
|
ticket_number: string;
|
|
analysis_version: number;
|
|
has_fingerprint: boolean;
|
|
is_stale: boolean;
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const { session, error } = await requireAuth();
|
|
if (error) return error;
|
|
|
|
const body = await request.json().catch(() => ({}));
|
|
const parsed = PostBody.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid request body', details: parsed.error.issues },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const { analysisIds, ticketNumbers, includeItglueContext, reportTitle } = parsed.data;
|
|
|
|
// Resolve analysisIds: explicit list > ticketNumbers (latest analysis per ticket).
|
|
let resolvedIds: string[] = [];
|
|
if (analysisIds && analysisIds.length > 0) {
|
|
resolvedIds = analysisIds;
|
|
} else if (ticketNumbers && ticketNumbers.length > 0) {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`SELECT DISTINCT ON (ticket_number) id::text AS id
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = ANY($1::text[])
|
|
AND status = 'complete'
|
|
ORDER BY ticket_number, analysis_version DESC`,
|
|
[ticketNumbers]
|
|
);
|
|
resolvedIds = res.rows.map((r) => r.id);
|
|
} else {
|
|
return NextResponse.json(
|
|
{ error: 'Provide analysisIds or ticketNumbers' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
if (resolvedIds.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'No matching complete analyses found for the given inputs' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (resolvedIds.length > MAX_TICKETS_PER_REPORT) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Too many tickets (${resolvedIds.length}). Cap is ${MAX_TICKETS_PER_REPORT}.`,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Validate fingerprints + staleness.
|
|
const validation = await postgresClient.query<FingerprintCheckRow>(
|
|
`SELECT aa.id::text AS id,
|
|
aa.ticket_number,
|
|
aa.analysis_version,
|
|
(aa.aggregate_fingerprint IS NOT NULL) AS has_fingerprint,
|
|
(t.last_activity_date > aa.completed_at) AS is_stale
|
|
FROM analyzer_analyses aa
|
|
LEFT JOIN tickets t ON t.ticket_number = aa.ticket_number
|
|
AND t.is_deleted = false
|
|
WHERE aa.id = ANY($1::uuid[])`,
|
|
[resolvedIds]
|
|
);
|
|
const missingFingerprint = validation.rows
|
|
.filter((r) => !r.has_fingerprint)
|
|
.map((r) => `${r.ticket_number} v${r.analysis_version}`);
|
|
const staleAnalyses = validation.rows
|
|
.filter((r) => r.is_stale)
|
|
.map((r) => `${r.ticket_number} v${r.analysis_version}`);
|
|
|
|
if (missingFingerprint.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Some analyses are missing aggregate_fingerprint',
|
|
missingFingerprint,
|
|
hint: 'Run scripts/backfill-fingerprints.ts to fill in legacy analyses, or re-analyze.',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (staleAnalyses.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Some selected tickets have new activity since their last analysis. Re-analyze first.',
|
|
staleAnalyses,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
|
|
|
// Cost-guard evaluation. Hard-blocks at $50/day, asks for confirmation at >$5.
|
|
const estimatedCost = estimateAggregateReportCost({
|
|
ticketCount: resolvedIds.length,
|
|
includeItglueContext: parsed.data.includeItglueContext,
|
|
});
|
|
const evaluation = await evaluateCost({
|
|
userId,
|
|
estimatedCost,
|
|
confirmedCost: parsed.data.confirmedCost,
|
|
});
|
|
await recordCostAuditDecision({
|
|
userId,
|
|
action: 'aggregate_report',
|
|
evaluation,
|
|
context: { ticketCount: resolvedIds.length, includeItglueContext: parsed.data.includeItglueContext },
|
|
});
|
|
if (evaluation.decision === 'blocked') {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Daily cost limit reached',
|
|
message: evaluation.decisionReason,
|
|
estimatedCost: evaluation.estimatedCost,
|
|
dailySpendBefore: evaluation.dailySpendBefore,
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
if (evaluation.decision === 'requires_confirmation') {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Confirmation required',
|
|
message: evaluation.decisionReason,
|
|
estimatedCost: evaluation.estimatedCost,
|
|
dailySpendBefore: evaluation.dailySpendBefore,
|
|
requiresConfirmation: true,
|
|
retryWith: { confirmedCost: true },
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const created = await createAggregateReport({
|
|
generatedByUserId: userId,
|
|
filterCriteria: { analysisIds: resolvedIds },
|
|
analysisIds: resolvedIds,
|
|
ticketCount: resolvedIds.length,
|
|
includeItglueContext,
|
|
reportTitle: reportTitle ?? null,
|
|
});
|
|
|
|
// Fire and forget — runner persists results when done.
|
|
void runAggregateReport(created.id).catch((err) => {
|
|
console.error('[ANALYZER-REPORT] background runner threw:', err);
|
|
});
|
|
|
|
return NextResponse.json({
|
|
reportId: created.id,
|
|
status: 'pending',
|
|
ticketCount: resolvedIds.length,
|
|
});
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const { error } = await requireAuth();
|
|
if (error) return error;
|
|
|
|
const url = new URL(request.url);
|
|
const limit = Number(url.searchParams.get('limit') ?? 50);
|
|
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
const reports = await listAggregateReports({ limit, offset });
|
|
return NextResponse.json({ reports });
|
|
}
|