wulf-pulse/app/api/analyzer/aggregate-reports/route.ts
lorentz bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
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>
2026-04-29 14:00:22 -04:00

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 });
}