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
218
lib/services/llm/call.ts
Normal file
218
lib/services/llm/call.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* Generic LLM caller for the analyzer pipeline.
|
||||
*
|
||||
* One round-trip is:
|
||||
* 1. Send (system, user) to the chosen model.
|
||||
* 2. Extract the assistant's text content.
|
||||
* 3. JSON.parse + Zod-validate against the caller's schema.
|
||||
* 4. On failure: retry ONCE with the prior raw response + parse error in a
|
||||
* follow-up user turn, then validate again.
|
||||
* 5. After two failures: throw.
|
||||
*
|
||||
* The system prompt is marked with `cache_control: ephemeral`. Anthropic
|
||||
* silently no-ops caching when the prefix is below the model's minimum
|
||||
* (~2-4K tokens) — for our short stage prompts this often won't fire, which
|
||||
* is fine; cost is unaffected when caching is skipped.
|
||||
*/
|
||||
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import type { ZodType } from 'zod';
|
||||
import { getAnthropicClient } from './client';
|
||||
import { estimateCostUsd, type TokenUsage } from './pricing';
|
||||
import { type ModelId, OPUS } from './models';
|
||||
|
||||
export interface LLMCallOptions<T> {
|
||||
model: ModelId;
|
||||
system: string;
|
||||
user: string;
|
||||
schema: ZodType<T>;
|
||||
maxTokens: number;
|
||||
/** Override the singleton (test injection). */
|
||||
client?: Anthropic;
|
||||
}
|
||||
|
||||
export interface LLMCallResult<T> {
|
||||
data: T;
|
||||
usage: TokenUsage;
|
||||
estimated_cost_usd: number;
|
||||
/** Number of attempts made (1 = first try succeeded; 2 = retry succeeded). */
|
||||
attempts: 1 | 2;
|
||||
/** Raw response text for debugging / model_traces. */
|
||||
raw_response: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate token usage from two calls (used to track total cost across
|
||||
* the original attempt + retry).
|
||||
*/
|
||||
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
||||
return {
|
||||
input_tokens: a.input_tokens + b.input_tokens,
|
||||
output_tokens: a.output_tokens + b.output_tokens,
|
||||
cache_creation_input_tokens:
|
||||
(a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0),
|
||||
cache_read_input_tokens:
|
||||
(a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
interface MessagesCreateBody {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
system: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
cache_control?: { type: 'ephemeral' };
|
||||
}>;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}
|
||||
|
||||
function buildBody(opts: {
|
||||
model: ModelId;
|
||||
system: string;
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
maxTokens: number;
|
||||
}): MessagesCreateBody {
|
||||
return {
|
||||
model: opts.model,
|
||||
max_tokens: opts.maxTokens,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: opts.system,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: opts.history,
|
||||
};
|
||||
}
|
||||
|
||||
function extractText(response: Anthropic.Message): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'text') parts.push(block.text);
|
||||
}
|
||||
return parts.join('').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Some models occasionally wrap JSON in code fences despite "respond ONLY with
|
||||
* JSON" instructions. Strip a single ```json ... ``` fence if present so the
|
||||
* happy path doesn't bounce into the retry just for that.
|
||||
*/
|
||||
function unwrapFences(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith('```')) {
|
||||
const stripped = trimmed
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/, '')
|
||||
.trim();
|
||||
return stripped;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function tryParseValidate<T>(
|
||||
text: string,
|
||||
schema: ZodType<T>
|
||||
): { ok: true; value: T } | { ok: false; error: string } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(unwrapFences(text));
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `JSON.parse failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Zod validation failed: ${JSON.stringify(result.error.issues, null, 2)}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
export async function callLLMStage<T>(
|
||||
opts: LLMCallOptions<T>
|
||||
): Promise<LLMCallResult<T>> {
|
||||
const client = opts.client ?? getAnthropicClient();
|
||||
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: opts.user },
|
||||
];
|
||||
|
||||
// Opus 4.7 rejects `temperature`, `top_p`, `top_k`. We don't pass any of
|
||||
// them, so the same body shape works on all three models.
|
||||
const firstBody = buildBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (firstBody satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
|
||||
const first = await client.messages.create(firstBody);
|
||||
const firstText = extractText(first);
|
||||
const firstParse = tryParseValidate(firstText, opts.schema);
|
||||
|
||||
if (firstParse.ok) {
|
||||
const usage: TokenUsage = first.usage as TokenUsage;
|
||||
return {
|
||||
data: firstParse.value,
|
||||
usage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, usage),
|
||||
attempts: 1,
|
||||
raw_response: firstText,
|
||||
};
|
||||
}
|
||||
|
||||
// Retry once. Append the model's previous (invalid) response and a follow-up
|
||||
// user turn explaining the parse error.
|
||||
history.push({ role: 'assistant', content: firstText });
|
||||
history.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
'Your previous response could not be parsed. Error:',
|
||||
firstParse.error,
|
||||
'',
|
||||
'Re-emit the SAME response, corrected to be valid JSON that conforms to the requested schema.',
|
||||
'Respond ONLY with JSON. No prose, no code fences.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const secondBody = buildBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (secondBody satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
|
||||
const second = await client.messages.create(secondBody);
|
||||
const secondText = extractText(second);
|
||||
const secondParse = tryParseValidate(secondText, opts.schema);
|
||||
|
||||
const totalUsage = addUsage(
|
||||
first.usage as TokenUsage,
|
||||
second.usage as TokenUsage
|
||||
);
|
||||
|
||||
if (!secondParse.ok) {
|
||||
throw new Error(
|
||||
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${secondText.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: secondParse.value,
|
||||
usage: totalUsage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, totalUsage),
|
||||
attempts: 2,
|
||||
raw_response: secondText,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export OPUS so callers don't need a second import for the common case.
|
||||
export { OPUS };
|
||||
Loading…
Add table
Add a link
Reference in a new issue