SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
11 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
8.7 KB · 154 lines typescript
Raw Blame History
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