import Anthropic from "@anthropic-ai/sdk"; import { EVENT_TYPES, type DiffResult, type HeuristicResult } from "@websensor/core"; import { db, llmUsage, sql } from "@websensor/db"; import { config, log } from "./config"; import { bumpDaily, m } from "./metrics"; /** * Stage-2 interpretation with Claude. Only called for candidates that already cleared the * heuristic bar and a preliminary importance threshold; a daily call budget caps spend. * Routine changes go to the fast model; high-importance candidates get the deep model. * Output is strictly structured (JSON schema) — never free text. */ export interface Interpretation { event_type: string; title: string; summary: string; why_it_matters: string; who_it_affects: string; observed: string; inferred: string; meaningful: boolean; severity: number; confidence: number; entities: string[]; keywords: string[]; announced: boolean | null; model: string; input_tokens: number; output_tokens: number; } const client = config.llm.apiKey ? new Anthropic({ apiKey: config.llm.apiKey, timeout: 60_000, maxRetries: 1 }) : null; const SYSTEM = `You are the interpretation stage of WebSensor, a platform that monitors official public web sources and turns detected changes into precise, sober intelligence events. You receive a detected change on one monitored endpoint: the source, the URL, the sensor kind, the heuristic pre-classification, extracted facts and the diff (before → after, or new/removed items). Your job: decide whether the change is meaningful to a professional audience, classify it, and write a factual title and summary. Rules: - Never sensationalize. No marketing tone. State what is observed; keep inference clearly separate. - "observed" must only contain facts visible in the diff. "inferred" may contain a cautious interpretation or be empty. - Titles: ≤ 110 characters, start with the organization name, no trailing period, no emoji. - Summary: 1–3 sentences, concrete (numbers, names, versions when present). - meaningful=false for cosmetic edits, typos, navigation/footer churn, copyright years, tracking or timestamp noise, and generic marketing rewording with no new information. - severity: 0–100 intrinsic importance of this kind of change for people who follow this organization (pricing/security/outage/model launch high; doc typo low). - confidence: 0–100 how sure you are of the classification given the evidence. - entities: organizations, products, models, APIs, drugs, standards explicitly named in the change (canonical names). - announced: true if the diff itself is an announcement (news/blog/release item), false if it is a silent modification of an existing page, null if unclear. Allowed event_type values: ${Object.keys(EVENT_TYPES).join(", ")}.`; const schema = { type: "object", additionalProperties: false, properties: { event_type: { type: "string", enum: Object.keys(EVENT_TYPES) }, title: { type: "string" }, summary: { type: "string" }, why_it_matters: { type: "string" }, who_it_affects: { type: "string" }, observed: { type: "string" }, inferred: { type: "string" }, meaningful: { type: "boolean" }, severity: { type: "integer", description: "0–100" }, confidence: { type: "integer", description: "0–100" }, entities: { type: "array", items: { type: "string" } }, keywords: { type: "array", items: { type: "string" } }, announced: { type: ["boolean", "null"] }, }, required: ["event_type", "title", "summary", "why_it_matters", "who_it_affects", "observed", "inferred", "meaningful", "severity", "confidence", "entities", "keywords", "announced"], } as const; let budgetDay = ""; let budgetUsed = 0; async function loadBudget(): Promise { const today = new Date().toISOString().slice(0, 10); if (budgetDay === today) return; budgetDay = today; const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from llm_usage where at >= current_date`); budgetUsed = Number(r.rows[0]?.n ?? 0); } export function llmAvailable(): boolean { return client !== null; } export async function interpretChange(input: { sourceName: string; sourceCategories: string[]; url: string; sensorName: string; sensorType: string; heuristic: HeuristicResult; diff: DiffResult; prelimImportance: number; title?: string | null }): Promise { if (!client) return null; await loadBudget(); if (budgetUsed >= config.llm.dailyCallBudget) { log.warn({ used: budgetUsed }, "LLM daily budget exhausted — heuristics only"); return null; } const deep = input.prelimImportance >= config.llm.deepMinImportance; const model = deep ? config.llm.modelDeep : config.llm.modelFast; const diffText = renderDiff(input.diff).slice(0, 14_000); const user = `SOURCE: ${input.sourceName} (${input.sourceCategories.join(", ") || "n/a"}) URL: ${input.url} SENSOR: ${input.sensorName} [${input.sensorType}]${input.title ? `\nPAGE TITLE: ${input.title}` : ""} HEURISTIC: type=${input.heuristic.eventType} signal=${input.heuristic.signal.toFixed(2)} magnitude=${input.heuristic.magnitude} noise=${input.heuristic.noiseRatio.toFixed(2)} reasons=${input.heuristic.reasons.join("; ") || "none"} FACTS: ${input.heuristic.facts.map((f) => `${f.kind}: ${f.before ?? "∅"} → ${f.after ?? "∅"}`).join(" | ") || "none"} DIFF: ${diffText}`; budgetUsed++; const started = Date.now(); try { // Haiku 4.5 does not accept `effort`; the deep model (Opus 5) runs adaptive thinking at medium effort. const params: Anthropic.MessageCreateParamsNonStreaming = { model, max_tokens: 1500, system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }], messages: [{ role: "user", content: user }], output_config: { format: { type: "json_schema", schema: schema as unknown as Record }, ...(deep ? { effort: "medium" as const } : {}) }, }; const res = await client.messages.create(params); const text = res.content.find((b) => b.type === "text")?.text ?? ""; if (res.stop_reason === "refusal" || !text) throw new Error(`no text (stop_reason=${res.stop_reason})`); const parsed = JSON.parse(text) as Omit; const usage = { input: res.usage.input_tokens + (res.usage.cache_read_input_tokens ?? 0) + (res.usage.cache_creation_input_tokens ?? 0), output: res.usage.output_tokens }; m.llmCalls.inc({ model, ok: "true" }); m.llmTokens.inc({ model, direction: "input" }, usage.input); m.llmTokens.inc({ model, direction: "output" }, usage.output); await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: usage.input, outputTokens: usage.output, ok: true }); await bumpDaily({ llm_calls: 1, llm_input_tokens: usage.input, llm_output_tokens: usage.output }); log.debug({ model, ms: Date.now() - started, type: parsed.event_type, meaningful: parsed.meaningful }, "llm interpretation"); if (!(parsed.event_type in EVENT_TYPES)) parsed.event_type = input.heuristic.eventType; parsed.severity = Math.max(0, Math.min(100, Math.round(Number(parsed.severity) || 0))); parsed.confidence = Math.max(0, Math.min(100, Math.round(Number(parsed.confidence) || 0))); return { ...parsed, model, input_tokens: usage.input, output_tokens: usage.output }; } catch (e) { m.llmCalls.inc({ model, ok: "false" }); await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: 0, outputTokens: 0, ok: false }).catch(() => undefined); const err = e as Error & { status?: number }; log.warn({ model, status: err.status, err: err.message }, "llm interpretation failed — falling back to heuristics"); return null; } } export function renderDiff(d: DiffResult): string { if (d.kind === "text") { const lines: string[] = []; for (const mo of d.modified.slice(0, 60)) lines.push(`- ${mo.before}\n+ ${mo.after}`); for (const r of d.removed.slice(0, 60)) lines.push(`- ${r}`); for (const a of d.added.slice(0, 80)) lines.push(`+ ${a}`); return lines.join("\n"); } if (d.kind === "json") return d.unified; const lines: string[] = []; for (const a of d.added.slice(0, 30)) lines.push(`+ NEW: ${String(a.title ?? a.url ?? a.key)}${a.summary ? `\n ${String(a.summary).slice(0, 500)}` : ""}${a.url ? `\n ${String(a.url)}` : ""}${a.publishedAt ? `\n published ${String(a.publishedAt)}` : ""}`); for (const r of d.removed.slice(0, 30)) lines.push(`- REMOVED: ${String(r.title ?? r.url ?? r.key)}`); for (const mo of d.modified.slice(0, 30)) lines.push(`~ UPDATED: ${String(mo.after.title ?? mo.key)} (${mo.fields.join(", ")})\n before: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.before[f])}`).join(" ")}\n after: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.after[f])}`).join(" ")}`); return lines.join("\n"); }