TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import Anthropic from "@anthropic-ai/sdk";2import { EVENT_TYPES, type DiffResult, type HeuristicResult } from "@websensor/core";3import { db, llmUsage, sql } from "@websensor/db";4import { config, log } from "./config";5import { bumpDaily, m } from "./metrics";67/**8 * Stage-2 interpretation with Claude. Only called for candidates that already cleared the9 * heuristic bar and a preliminary importance threshold; a daily call budget caps spend.10 * Routine changes go to the fast model; high-importance candidates get the deep model.11 * Output is strictly structured (JSON schema) — never free text.12 */13export interface Interpretation {14 event_type: string;15 title: string;16 summary: string;17 why_it_matters: string;18 who_it_affects: string;19 observed: string;20 inferred: string;21 meaningful: boolean;22 severity: number;23 confidence: number;24 entities: string[];25 keywords: string[];26 announced: boolean | null;27 model: string;28 input_tokens: number;29 output_tokens: number;30}3132const client = config.llm.apiKey ? new Anthropic({ apiKey: config.llm.apiKey, timeout: 60_000, maxRetries: 1 }) : null;3334const 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.35You 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).36Your job: decide whether the change is meaningful to a professional audience, classify it, and write a factual title and summary.37Rules:38- Never sensationalize. No marketing tone. State what is observed; keep inference clearly separate.39- "observed" must only contain facts visible in the diff. "inferred" may contain a cautious interpretation or be empty.40- Titles: ≤ 110 characters, start with the organization name, no trailing period, no emoji.41- Summary: 1–3 sentences, concrete (numbers, names, versions when present).42- meaningful=false for cosmetic edits, typos, navigation/footer churn, copyright years, tracking or timestamp noise, and generic marketing rewording with no new information.43- 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).44- confidence: 0–100 how sure you are of the classification given the evidence.45- entities: organizations, products, models, APIs, drugs, standards explicitly named in the change (canonical names).46- 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.47Allowed event_type values: ${Object.keys(EVENT_TYPES).join(", ")}.`;4849const schema = {50 type: "object",51 additionalProperties: false,52 properties: {53 event_type: { type: "string", enum: Object.keys(EVENT_TYPES) },54 title: { type: "string" },55 summary: { type: "string" },56 why_it_matters: { type: "string" },57 who_it_affects: { type: "string" },58 observed: { type: "string" },59 inferred: { type: "string" },60 meaningful: { type: "boolean" },61 severity: { type: "integer", description: "0–100" },62 confidence: { type: "integer", description: "0–100" },63 entities: { type: "array", items: { type: "string" } },64 keywords: { type: "array", items: { type: "string" } },65 announced: { type: ["boolean", "null"] },66 },67 required: ["event_type", "title", "summary", "why_it_matters", "who_it_affects", "observed", "inferred", "meaningful", "severity", "confidence", "entities", "keywords", "announced"],68} as const;6970let budgetDay = "";71let budgetUsed = 0;7273async function loadBudget(): Promise<void> {74 const today = new Date().toISOString().slice(0, 10);75 if (budgetDay === today) return;76 budgetDay = today;77 const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from llm_usage where at >= current_date`);78 budgetUsed = Number(r.rows[0]?.n ?? 0);79}8081export function llmAvailable(): boolean {82 return client !== null;83}8485export async function interpretChange(input: { sourceName: string; sourceCategories: string[]; url: string; sensorName: string; sensorType: string; heuristic: HeuristicResult; diff: DiffResult; prelimImportance: number; title?: string | null }): Promise<Interpretation | null> {86 if (!client) return null;87 await loadBudget();88 if (budgetUsed >= config.llm.dailyCallBudget) {89 log.warn({ used: budgetUsed }, "LLM daily budget exhausted — heuristics only");90 return null;91 }92 const deep = input.prelimImportance >= config.llm.deepMinImportance;93 const model = deep ? config.llm.modelDeep : config.llm.modelFast;94 const diffText = renderDiff(input.diff).slice(0, 14_000);95 const user = `SOURCE: ${input.sourceName} (${input.sourceCategories.join(", ") || "n/a"})96URL: ${input.url}97SENSOR: ${input.sensorName} [${input.sensorType}]${input.title ? `\nPAGE TITLE: ${input.title}` : ""}98HEURISTIC: 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"}99FACTS: ${input.heuristic.facts.map((f) => `${f.kind}: ${f.before ?? "∅"} → ${f.after ?? "∅"}`).join(" | ") || "none"}100101DIFF:102${diffText}`;103104 budgetUsed++;105 const started = Date.now();106 try {107 // Haiku 4.5 does not accept `effort`; the deep model (Opus 5) runs adaptive thinking at medium effort.108 const params: Anthropic.MessageCreateParamsNonStreaming = {109 model,110 max_tokens: 1500,111 system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],112 messages: [{ role: "user", content: user }],113 output_config: { format: { type: "json_schema", schema: schema as unknown as Record<string, unknown> }, ...(deep ? { effort: "medium" as const } : {}) },114 };115 const res = await client.messages.create(params);116 const text = res.content.find((b) => b.type === "text")?.text ?? "";117 if (res.stop_reason === "refusal" || !text) throw new Error(`no text (stop_reason=${res.stop_reason})`);118 const parsed = JSON.parse(text) as Omit<Interpretation, "model" | "input_tokens" | "output_tokens">;119 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 };120 m.llmCalls.inc({ model, ok: "true" });121 m.llmTokens.inc({ model, direction: "input" }, usage.input);122 m.llmTokens.inc({ model, direction: "output" }, usage.output);123 await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: usage.input, outputTokens: usage.output, ok: true });124 await bumpDaily({ llm_calls: 1, llm_input_tokens: usage.input, llm_output_tokens: usage.output });125 log.debug({ model, ms: Date.now() - started, type: parsed.event_type, meaningful: parsed.meaningful }, "llm interpretation");126 if (!(parsed.event_type in EVENT_TYPES)) parsed.event_type = input.heuristic.eventType;127 parsed.severity = Math.max(0, Math.min(100, Math.round(Number(parsed.severity) || 0)));128 parsed.confidence = Math.max(0, Math.min(100, Math.round(Number(parsed.confidence) || 0)));129 return { ...parsed, model, input_tokens: usage.input, output_tokens: usage.output };130 } catch (e) {131 m.llmCalls.inc({ model, ok: "false" });132 await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: 0, outputTokens: 0, ok: false }).catch(() => undefined);133 const err = e as Error & { status?: number };134 log.warn({ model, status: err.status, err: err.message }, "llm interpretation failed — falling back to heuristics");135 return null;136 }137}138139export function renderDiff(d: DiffResult): string {140 if (d.kind === "text") {141 const lines: string[] = [];142 for (const mo of d.modified.slice(0, 60)) lines.push(`- ${mo.before}\n+ ${mo.after}`);143 for (const r of d.removed.slice(0, 60)) lines.push(`- ${r}`);144 for (const a of d.added.slice(0, 80)) lines.push(`+ ${a}`);145 return lines.join("\n");146 }147 if (d.kind === "json") return d.unified;148 const lines: string[] = [];149 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)}` : ""}`);150 for (const r of d.removed.slice(0, 30)) lines.push(`- REMOVED: ${String(r.title ?? r.url ?? r.key)}`);151 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(" ")}`);152 return lines.join("\n");153}154