wulf-pulse/lib/services/analyzer/aggregate-persistence.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

506 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Persistence + runner for aggregate reports.
*
* Spec: docs/ticket-analyzer-phase2-spec.md → Sections D.4D.6
*/
import postgresClient from '@/lib/services/postgres-client';
import {
type AggregateFingerprint,
type AggregateReduceResponse,
type AggregateReportStatus,
type StageExecutionRecord,
} from '@/lib/types/analyzer';
import { getITGlueClient } from '@/lib/services/itglue-client';
import { runAggregateReduceStage } from './stages/aggregate-reduce';
interface AggregateReportRow {
id: string;
generated_by_user_id: string | null;
generated_at: Date;
filter_criteria: unknown;
analysis_ids: string[];
ticket_count: number;
include_itglue_context: boolean;
report_title: string | null;
category_distribution: Record<string, number> | null;
client_distribution: Record<string, number> | null;
resolution_path_distribution: Record<string, number> | null;
root_cause_distribution: Record<string, number> | null;
date_range_actual: { earliest: string | null; latest: string | null } | null;
documentation_gaps: unknown;
process_gaps: unknown;
client_patterns: unknown;
recurrence_clusters: unknown;
systemic_observations: unknown;
recommended_actions: unknown;
narrative_summary: string | null;
executive_summary: string | null;
total_input_tokens: number | null;
total_output_tokens: number | null;
estimated_cost_usd: string | null;
model_used: string | null;
itglue_context_included: boolean | null;
status: AggregateReportStatus;
error_message: string | null;
}
export interface AggregateReportSummary {
id: string;
generatedByUserId: string | null;
generatedAt: string;
filterCriteria: unknown;
analysisIds: string[];
ticketCount: number;
includeItglueContext: boolean;
reportTitle: string | null;
status: AggregateReportStatus;
errorMessage: string | null;
// SQL outputs
categoryDistribution: Record<string, number> | null;
clientDistribution: Record<string, number> | null;
resolutionPathDistribution: Record<string, number> | null;
rootCauseDistribution: Record<string, number> | null;
dateRangeActual: { earliest: string | null; latest: string | null } | null;
// LLM outputs
documentationGaps: unknown;
processGaps: unknown;
clientPatterns: unknown;
recurrenceClusters: unknown;
systemicObservations: unknown;
recommendedActions: unknown;
narrativeSummary: string | null;
executiveSummary: string | null;
// Cost
totalInputTokens: number | null;
totalOutputTokens: number | null;
estimatedCostUsd: number | null;
modelUsed: string | null;
}
function rowToSummary(r: AggregateReportRow): AggregateReportSummary {
return {
id: r.id,
generatedByUserId: r.generated_by_user_id,
generatedAt: r.generated_at.toISOString(),
filterCriteria: r.filter_criteria,
analysisIds: r.analysis_ids,
ticketCount: r.ticket_count,
includeItglueContext: r.include_itglue_context,
reportTitle: r.report_title,
status: r.status,
errorMessage: r.error_message,
categoryDistribution: r.category_distribution,
clientDistribution: r.client_distribution,
resolutionPathDistribution: r.resolution_path_distribution,
rootCauseDistribution: r.root_cause_distribution,
dateRangeActual: r.date_range_actual,
documentationGaps: r.documentation_gaps,
processGaps: r.process_gaps,
clientPatterns: r.client_patterns,
recurrenceClusters: r.recurrence_clusters,
systemicObservations: r.systemic_observations,
recommendedActions: r.recommended_actions,
narrativeSummary: r.narrative_summary,
executiveSummary: r.executive_summary,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
estimatedCostUsd: r.estimated_cost_usd === null ? null : Number(r.estimated_cost_usd),
modelUsed: r.model_used,
};
}
const REPORT_SELECT = `
id::text AS id,
generated_by_user_id, generated_at,
filter_criteria, analysis_ids::text[] AS analysis_ids,
ticket_count, include_itglue_context, report_title,
category_distribution, client_distribution, resolution_path_distribution,
root_cause_distribution, date_range_actual,
documentation_gaps, process_gaps, client_patterns, recurrence_clusters,
systemic_observations, recommended_actions,
narrative_summary, executive_summary,
total_input_tokens, total_output_tokens,
estimated_cost_usd::text AS estimated_cost_usd,
model_used, itglue_context_included,
status, error_message
`;
export interface CreateAggregateReportInput {
generatedByUserId: string | null;
filterCriteria: unknown;
analysisIds: string[];
ticketCount: number;
includeItglueContext: boolean;
reportTitle: string | null;
}
export async function createAggregateReport(
input: CreateAggregateReportInput
): Promise<{ id: string }> {
const res = await postgresClient.query<{ id: string }>(
`INSERT INTO analyzer_aggregate_reports
(generated_by_user_id, filter_criteria, analysis_ids,
ticket_count, include_itglue_context, report_title, status)
VALUES ($1, $2::jsonb, $3::uuid[], $4, $5, $6, 'pending')
RETURNING id::text AS id`,
[
input.generatedByUserId,
JSON.stringify(input.filterCriteria),
input.analysisIds,
input.ticketCount,
input.includeItglueContext,
input.reportTitle,
]
);
return { id: res.rows[0].id };
}
export async function getAggregateReport(
id: string
): Promise<AggregateReportSummary | null> {
const res = await postgresClient.query<AggregateReportRow>(
`SELECT ${REPORT_SELECT} FROM analyzer_aggregate_reports WHERE id = $1`,
[id]
);
if (res.rowCount === 0) return null;
return rowToSummary(res.rows[0]);
}
export async function listAggregateReports(opts: {
limit?: number;
offset?: number;
generatedByUserId?: string;
}): Promise<AggregateReportSummary[]> {
const limit = Math.min(opts.limit ?? 50, 200);
const offset = opts.offset ?? 0;
const params: unknown[] = [limit, offset];
let userClause = '';
if (opts.generatedByUserId) {
params.push(opts.generatedByUserId);
userClause = `WHERE generated_by_user_id = $${params.length}`;
}
const res = await postgresClient.query<AggregateReportRow>(
`SELECT ${REPORT_SELECT}
FROM analyzer_aggregate_reports
${userClause}
ORDER BY generated_at DESC
LIMIT $1 OFFSET $2`,
params
);
return res.rows.map(rowToSummary);
}
interface FingerprintRow {
id: string;
ticket_number: string;
aggregate_fingerprint: AggregateFingerprint;
triggered_at: Date;
}
async function loadFingerprints(
analysisIds: string[]
): Promise<{ ticket_number: string; fingerprint: AggregateFingerprint; triggered_at: Date }[]> {
if (analysisIds.length === 0) return [];
const res = await postgresClient.query<FingerprintRow>(
`SELECT id::text AS id, ticket_number, aggregate_fingerprint, triggered_at
FROM analyzer_analyses
WHERE id = ANY($1::uuid[])
AND aggregate_fingerprint IS NOT NULL
ORDER BY ticket_number, analysis_version DESC`,
[analysisIds]
);
return res.rows.map((r) => ({
ticket_number: r.ticket_number,
fingerprint: r.aggregate_fingerprint,
triggered_at: r.triggered_at,
}));
}
function bucketCount(items: string[]): Record<string, number> {
const out: Record<string, number> = {};
for (const i of items) out[i] = (out[i] ?? 0) + 1;
return out;
}
async function fetchITGlueDocTitles(
clientNames: string[]
): Promise<{ client_name: string; doc_titles: string[] }[]> {
let client;
try {
client = getITGlueClient();
} catch {
return []; // not configured — caller should fall back gracefully
}
const result: { client_name: string; doc_titles: string[] }[] = [];
for (const name of clientNames) {
try {
const org = await client.findOrganizationByName(name);
if (!org) continue;
const docs = await client.getFlexibleAssets({ organizationId: org.id });
const titles = docs
.map((d) => (d as { name?: string }).name)
.filter((t): t is string => typeof t === 'string')
.slice(0, 50);
result.push({ client_name: name, doc_titles: titles });
} catch {
// Tolerate per-client failures.
}
}
return result;
}
const ITGLUE_DOC_TITLE_CAP = 200;
export async function bulkInsertReportStageExecutions(
reportId: string,
records: StageExecutionRecord[]
): Promise<void> {
if (records.length === 0) return;
const values: unknown[] = [reportId];
const tuples: string[] = [];
for (const r of records) {
const base = values.length;
values.push(
r.stage,
r.stage_order,
r.model_id,
JSON.stringify(r.input_payload ?? {}),
JSON.stringify(r.output_payload ?? {}),
r.input_tokens,
r.output_tokens,
r.latency_ms,
r.started_at,
r.completed_at,
r.error_message
);
tuples.push(
`($1, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb, ` +
`$${base + 5}::jsonb, $${base + 6}, $${base + 7}, $${base + 8}, ` +
`$${base + 9}, $${base + 10}, $${base + 11})`
);
}
await postgresClient.query(
`INSERT INTO analyzer_stage_executions
(aggregate_report_id, stage, stage_order, model_id,
input_payload, output_payload,
input_tokens, output_tokens, latency_ms,
started_at, completed_at, error_message)
VALUES ${tuples.join(', ')}`,
values
);
}
/**
* Fire-and-forget runner. Intended to be invoked from the POST endpoint with
* `void runAggregateReport(id)` — the route returns immediately, this updates
* the row when work completes (or fails).
*/
export async function runAggregateReport(reportId: string): Promise<void> {
const stageRecords: StageExecutionRecord[] = [];
try {
await postgresClient.query(
`UPDATE analyzer_aggregate_reports SET status = 'running' WHERE id = $1`,
[reportId]
);
const report = await getAggregateReport(reportId);
if (!report) throw new Error('report row vanished');
// ── Step 1: load fingerprints + compute SQL distributions ──
const sqlStart = new Date();
const fingerprints = await loadFingerprints(report.analysisIds);
if (fingerprints.length === 0) {
throw new Error('no analyses with fingerprints found for the given IDs');
}
const categories = bucketCount(fingerprints.map((f) => f.fingerprint.category));
const clients = bucketCount(fingerprints.map((f) => f.fingerprint.client_name));
const resolutionPaths = bucketCount(
fingerprints.map((f) => f.fingerprint.resolution_path)
);
const rootCauses = bucketCount(
fingerprints.map((f) => f.fingerprint.root_cause_class)
);
const dates = fingerprints.map((f) => f.triggered_at.getTime());
const dateRange = {
earliest: new Date(Math.min(...dates)).toISOString(),
latest: new Date(Math.max(...dates)).toISOString(),
};
const sqlEnd = new Date();
stageRecords.push({
stage: 'analyze', // Reusing 'analyze' since CHECK constraint enumerates only stage names; a future migration could add 'aggregate_sql' / 'aggregate_reduce'.
stage_order: 1,
model_id: null,
input_payload: { analysis_ids: report.analysisIds },
output_payload: {
category_distribution: categories,
client_distribution: clients,
resolution_path_distribution: resolutionPaths,
root_cause_distribution: rootCauses,
date_range_actual: dateRange,
fingerprint_count: fingerprints.length,
},
input_tokens: null,
output_tokens: null,
latency_ms: sqlEnd.getTime() - sqlStart.getTime(),
started_at: sqlStart,
completed_at: sqlEnd,
error_message: null,
});
// Persist partial results immediately so UI can show distributions.
await postgresClient.query(
`UPDATE analyzer_aggregate_reports
SET category_distribution = $2::jsonb,
client_distribution = $3::jsonb,
resolution_path_distribution = $4::jsonb,
root_cause_distribution = $5::jsonb,
date_range_actual = $6::jsonb
WHERE id = $1`,
[
reportId,
JSON.stringify(categories),
JSON.stringify(clients),
JSON.stringify(resolutionPaths),
JSON.stringify(rootCauses),
JSON.stringify(dateRange),
]
);
// ── Step 2: IT Glue context (optional) ──
let itglueDocTitles: { client_name: string; doc_titles: string[] }[] | undefined;
let itglueIncluded = false;
if (report.includeItglueContext) {
const uniqueClients = Array.from(
new Set(fingerprints.map((f) => f.fingerprint.client_name))
);
const itglueStart = new Date();
itglueDocTitles = await fetchITGlueDocTitles(uniqueClients);
// Cap to spec total (200 doc titles across all clients).
let remaining = ITGLUE_DOC_TITLE_CAP;
itglueDocTitles = itglueDocTitles.map((c) => {
if (remaining <= 0) return { client_name: c.client_name, doc_titles: [] };
const titles = c.doc_titles.slice(0, remaining);
remaining -= titles.length;
return { client_name: c.client_name, doc_titles: titles };
});
itglueIncluded = itglueDocTitles.some((c) => c.doc_titles.length > 0);
const itglueEnd = new Date();
stageRecords.push({
stage: 'itglue',
stage_order: 2,
model_id: null,
input_payload: { client_count: uniqueClients.length },
output_payload: { doc_count: itglueDocTitles.reduce((a, c) => a + c.doc_titles.length, 0) },
input_tokens: null,
output_tokens: null,
latency_ms: itglueEnd.getTime() - itglueStart.getTime(),
started_at: itglueStart,
completed_at: itglueEnd,
error_message: null,
});
}
// ── Step 3: reduce LLM call ──
const reduceStart = new Date();
let reduceResult;
try {
reduceResult = await runAggregateReduceStage({
distributions: {
category_distribution: categories,
client_distribution: clients,
resolution_path_distribution: resolutionPaths,
root_cause_distribution: rootCauses,
date_range_actual: dateRange,
},
fingerprints: fingerprints.map((f) => ({
ticket_number: f.ticket_number,
fingerprint: f.fingerprint,
})),
itglue_doc_titles: itglueDocTitles,
});
} catch (err) {
const reduceEnd = new Date();
stageRecords.push({
stage: 'analyze',
stage_order: 3,
model_id: null,
input_payload: { fingerprint_count: fingerprints.length },
output_payload: {},
input_tokens: null,
output_tokens: null,
latency_ms: reduceEnd.getTime() - reduceStart.getTime(),
started_at: reduceStart,
completed_at: reduceEnd,
error_message: err instanceof Error ? err.message : String(err),
});
throw err;
}
const reduceEnd = new Date();
stageRecords.push({
stage: 'analyze',
stage_order: 3,
model_id: reduceResult.model_used,
input_payload: { fingerprint_count: fingerprints.length },
output_payload: reduceResult.data,
input_tokens: reduceResult.usage.input_tokens,
output_tokens: reduceResult.usage.output_tokens,
latency_ms: reduceEnd.getTime() - reduceStart.getTime(),
started_at: reduceStart,
completed_at: reduceEnd,
error_message: null,
});
// ── Step 4: persist outputs ──
await postgresClient.query(
`UPDATE analyzer_aggregate_reports
SET documentation_gaps = $2::jsonb,
process_gaps = $3::jsonb,
client_patterns = $4::jsonb,
recurrence_clusters = $5::jsonb,
systemic_observations = $6::jsonb,
recommended_actions = $7::jsonb,
narrative_summary = $8,
executive_summary = $9,
total_input_tokens = $10,
total_output_tokens = $11,
estimated_cost_usd = $12,
model_used = $13,
itglue_context_included = $14,
status = 'complete'
WHERE id = $1`,
[
reportId,
JSON.stringify(reduceResult.data.documentation_gaps),
JSON.stringify(reduceResult.data.process_gaps),
JSON.stringify(reduceResult.data.client_patterns),
JSON.stringify(reduceResult.data.recurrence_clusters),
JSON.stringify(reduceResult.data.systemic_observations),
JSON.stringify(reduceResult.data.recommended_actions),
reduceResult.data.narrative_summary,
reduceResult.data.executive_summary,
reduceResult.usage.input_tokens,
reduceResult.usage.output_tokens,
reduceResult.estimated_cost_usd,
reduceResult.model_used,
itglueIncluded,
]
);
await bulkInsertReportStageExecutions(reportId, stageRecords);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[ANALYZER-REPORT] runAggregateReport ${reportId} failed:`, message);
await postgresClient
.query(
`UPDATE analyzer_aggregate_reports
SET status = 'failed', error_message = $2
WHERE id = $1`,
[reportId, message]
)
.catch(() => {});
if (stageRecords.length > 0) {
await bulkInsertReportStageExecutions(reportId, stageRecords).catch(() => {});
}
}
}