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>
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
/**
|
|
* Cost guards for analyzer LLM operations.
|
|
*
|
|
* Spec: docs/ticket-analyzer-phase2-spec.md → Section D.8.
|
|
*
|
|
* Three thresholds:
|
|
* • $5 per request → require explicit confirmation in UI
|
|
* • $20 per user/day → soft warn (returned in preflight; UI can surface)
|
|
* • $50 per user/day → hard block (unless user is in override list)
|
|
*
|
|
* Override env var: ANALYZER_DAILY_COST_OVERRIDE_USERS (comma-separated user ids)
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
export const REQUIRES_CONFIRMATION_USD = 5.0;
|
|
export const SOFT_WARN_DAILY_USD = 20.0;
|
|
export const HARD_BLOCK_DAILY_USD = 50.0;
|
|
|
|
export type CostDecision =
|
|
| 'approved'
|
|
| 'requires_confirmation'
|
|
| 'blocked'
|
|
| 'overridden';
|
|
|
|
export interface CostEvaluation {
|
|
estimatedCost: number;
|
|
dailySpendBefore: number;
|
|
decision: CostDecision;
|
|
decisionReason: string | null;
|
|
softWarn: boolean;
|
|
hardBlocked: boolean;
|
|
requiresConfirmation: boolean;
|
|
isOverride: boolean;
|
|
}
|
|
|
|
/**
|
|
* Pessimistic cost estimate for an aggregate report. Assumes Sonnet pricing
|
|
* and a payload size proportional to the number of fingerprints (each ~2KB
|
|
* after compaction). Conservative — actual cost is usually lower.
|
|
*/
|
|
export function estimateAggregateReportCost(input: {
|
|
ticketCount: number;
|
|
includeItglueContext: boolean;
|
|
}): number {
|
|
// Inputs:
|
|
// per-fingerprint input tokens ≈ 700 (compacted JSON) → 700 * N
|
|
// distributions + system prompt ≈ 2000 tokens
|
|
// IT Glue context (if included): up to 200 doc titles * 50 tokens = 10K tokens
|
|
// Outputs: cap ≈ 6K tokens (we set max_tokens 16K but real outputs are smaller).
|
|
const inputTokens =
|
|
700 * input.ticketCount +
|
|
2000 +
|
|
(input.includeItglueContext ? 10_000 : 0);
|
|
const outputTokens = 6_000;
|
|
// Sonnet pricing per Phase 1 pricing table: $3/$15 per 1M tokens.
|
|
const cost = (inputTokens / 1_000_000) * 3 + (outputTokens / 1_000_000) * 15;
|
|
return Math.round(cost * 10_000) / 10_000;
|
|
}
|
|
|
|
function getOverrideUsers(): Set<string> {
|
|
return new Set(
|
|
(process.env.ANALYZER_DAILY_COST_OVERRIDE_USERS ?? '')
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
);
|
|
}
|
|
|
|
export async function getUserDailySpend(userId: string | null): Promise<number> {
|
|
if (!userId) return 0;
|
|
// Sum from analyzer_aggregate_reports + analyzer_analyses for the trailing 24h.
|
|
const res = await postgresClient.query<{ total: string }>(
|
|
`SELECT COALESCE(SUM(amt), 0)::text AS total FROM (
|
|
SELECT estimated_cost_usd AS amt
|
|
FROM analyzer_aggregate_reports
|
|
WHERE generated_by_user_id = $1
|
|
AND generated_at >= NOW() - INTERVAL '24 hours'
|
|
AND estimated_cost_usd IS NOT NULL
|
|
UNION ALL
|
|
SELECT estimated_cost_usd AS amt
|
|
FROM analyzer_analyses
|
|
WHERE triggered_by_user_id = $1
|
|
AND triggered_at >= NOW() - INTERVAL '24 hours'
|
|
AND status = 'complete'
|
|
) x`,
|
|
[userId]
|
|
);
|
|
return Number(res.rows[0]?.total ?? 0);
|
|
}
|
|
|
|
export async function evaluateCost(input: {
|
|
userId: string | null;
|
|
estimatedCost: number;
|
|
confirmedCost: boolean;
|
|
}): Promise<CostEvaluation> {
|
|
const dailySpendBefore = await getUserDailySpend(input.userId);
|
|
const projectedDailySpend = dailySpendBefore + input.estimatedCost;
|
|
const overrideUsers = getOverrideUsers();
|
|
const isOverride = input.userId !== null && overrideUsers.has(input.userId);
|
|
|
|
const softWarn = projectedDailySpend >= SOFT_WARN_DAILY_USD;
|
|
const hardBlocked = projectedDailySpend >= HARD_BLOCK_DAILY_USD;
|
|
const requiresConfirmation =
|
|
input.estimatedCost > REQUIRES_CONFIRMATION_USD && !input.confirmedCost;
|
|
|
|
let decision: CostDecision;
|
|
let decisionReason: string | null = null;
|
|
if (hardBlocked && !isOverride) {
|
|
decision = 'blocked';
|
|
decisionReason = `Projected daily spend $${projectedDailySpend.toFixed(2)} would exceed hard limit $${HARD_BLOCK_DAILY_USD.toFixed(2)}`;
|
|
} else if (hardBlocked && isOverride) {
|
|
decision = 'overridden';
|
|
decisionReason = `Override allowed (user in ANALYZER_DAILY_COST_OVERRIDE_USERS); projected $${projectedDailySpend.toFixed(2)}`;
|
|
} else if (requiresConfirmation) {
|
|
decision = 'requires_confirmation';
|
|
decisionReason = `Per-request cost $${input.estimatedCost.toFixed(2)} > confirmation threshold $${REQUIRES_CONFIRMATION_USD.toFixed(2)}`;
|
|
} else {
|
|
decision = 'approved';
|
|
}
|
|
|
|
return {
|
|
estimatedCost: input.estimatedCost,
|
|
dailySpendBefore,
|
|
decision,
|
|
decisionReason,
|
|
softWarn,
|
|
hardBlocked,
|
|
requiresConfirmation,
|
|
isOverride,
|
|
};
|
|
}
|
|
|
|
export async function recordCostAuditDecision(input: {
|
|
userId: string | null;
|
|
action: string;
|
|
evaluation: CostEvaluation;
|
|
context?: Record<string, unknown>;
|
|
}): Promise<void> {
|
|
await postgresClient.query(
|
|
`INSERT INTO analyzer_cost_audit
|
|
(user_id, action, estimated_cost, daily_spend_before,
|
|
decision, decision_reason, context)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
|
[
|
|
input.userId,
|
|
input.action,
|
|
input.evaluation.estimatedCost,
|
|
input.evaluation.dailySpendBefore,
|
|
input.evaluation.decision,
|
|
input.evaluation.decisionReason,
|
|
JSON.stringify(input.context ?? {}),
|
|
]
|
|
);
|
|
}
|