close
Skip to content
carldawsPublic

About

Probabilistic control flow for TypeScript - powered by TypeSafe's Jev

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Hunch

Probabilistic control flow for TypeScript.

Hunch lets you ask questions about text or application data and use the answers in your TypeScript code.

if (await hunch.almostCertainly("this order is fraudulent", { given: order })) {
  await holdOrder(order)
}

You can get a probability, choose between options, or rate something on a scale. Questions are written in plain English; answers come back as typed values.

Hunch asks a System One model, such as TypeSafe's Jev, which answers with calibrated probabilities rather than text. It is a port of the hunch Ruby gem.

Installation

npm install @carldaws/hunch

Point Hunch at a System One endpoint. Jev is available through OpenRouter:

lib/hunch.ts:

import { Hunch, SystemOne } from "@carldaws/hunch"

export const hunch = new Hunch({
  backend: new SystemOne({
    url: "https://openrouter.ai/api/alpha/decisions",
    apiKey: process.env.OPENROUTER_API_KEY,
    model: "typesafe/jev-1.13",
  }),
})

Usage

chance returns a probability between 0 and 1:

await hunch.chance("written by a real human, not spam", { given: bio }) // 0.87

Use a named threshold when you want a boolean:

await hunch.possibly("fraudulent", { given: order })        // chance >= 0.25
await hunch.likely("fraudulent", { given: order })          // chance >= 0.5
await hunch.probably("fraudulent", { given: order })        // chance >= 0.75
await hunch.almostCertainly("fraudulent", { given: order }) // chance >= 0.93

const hunch = new Hunch({ backend, levels: { definitely: 0.99 } })
await hunch.definitely("fraudulent", { given: order })      // chance >= 0.99

Change a built-in threshold the same way, for example new Hunch({ backend, levels: { probably: 0.8 } }).

pick chooses one of the options you provide:

await hunch.pick(["ham", "spam"], { given: email })                // "spam"
await hunch.pick({ urgent: "needs a reply today", routine: "can wait" },
                 { given: ticket })                                // "routine"

rate returns a rating with a position on an ordered scale:

const mood = await hunch.rate(["calm", "frustrated", "livid"], { given: email })
mood.level            // "frustrated"
mood.position         // 1.4
mood.atLeast("livid") // false

Use pick for categories such as billing, support, and sales. Use rate for ordered levels such as low, medium, and high.

Both accept an array of names, an object of name: "description" pairs, or an array mixing the two. Add question if the options need more context. given supplies the data to evaluate. The answer's type is the union of your option names.

Batching

Use hunch.decide to ask several questions about the same data in one API call:

const result = await hunch.decide({ given: email.raw }, (q) =>
  q
    .probably("urgent", "does this convey urgency?")
    .pick("team", { billing: "payments", technical: "bugs", sales: "pricing" })
    .rate("mood", ["calm", "frustrated", "livid"]),
)

result.urgent               // true, at or above probably
result.probabilities.urgent // 0.92
result.team                 // "technical"
result.probabilities.team   // { billing: 0.08, technical: 0.85, sales: 0.07 }
result.confidence.team      // 0.82
result.mood.level           // "livid"

In a Next.js app

The example/ app contains these examples and their tests. Each one imports the client above from lib/hunch.ts.

Validation

Check a display name and bio in a Zod schema:

lib/signup.ts:

import { APIError } from "@carldaws/hunch"
import { z } from "zod"
import { hunch } from "./hunch"

const failOpen = (check: Promise<boolean>) =>
  check.catch((error) => {
    if (error instanceof APIError) return true
    throw error
  })

export const Signup = z.object({
  email: z.email(),
  displayName: z
    .string()
    .optional()
    .refine(
      (name) =>
        !name ||
        failOpen(hunch.likely("a plausible human or company name, not an advert or URL", { given: name })),
      "doesn't look like a name",
    ),
  bio: z
    .string()
    .optional()
    .refine(
      (bio) => !bio || failOpen(hunch.probably("a genuine human bio, not spam or keyword stuffing", { given: bio })),
      "reads like spam",
    ),
})

Then use the schema in a server action:

app/signup/actions.ts:

"use server"

import { z } from "zod"
import { db } from "@/lib/db"
import { Signup } from "@/lib/signup"

export type SignupState = { errors?: Partial<Record<"email" | "displayName" | "bio", string[]>>; signedUp?: boolean }

export async function signUp(_state: SignupState, form: FormData): Promise<SignupState> {
  const parsed = await Signup.safeParseAsync(Object.fromEntries(form))
  if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors }

  const { email, displayName, bio } = parsed.data
  db.prepare("INSERT INTO signups (email, display_name, bio) VALUES (?, ?, ?)").run(email, displayName ?? null, bio ?? null)
  return { signedUp: true }
}

These checks treat APIError as a pass, so an API failure adds no validation error. Other checks, including the email format, still apply.

Inbound email

Route incoming email by its contents:

lib/tickets.ts:

import { db } from "./db"
import { hunch } from "./hunch"

export async function sortEmail({ subject, text }: { subject: string; text: string }) {
  const team = await hunch.pick(
    {
      support: "questions about using or configuring the product",
      billing: "invoices, payments, refunds",
      spam: "unsolicited bulk or scam email",
    },
    { given: `Subject: ${subject}\n\n${text}` },
  )
  if (team === "spam") return

  db.prepare("INSERT INTO tickets (team, subject, body) VALUES (?, ?, ?)").run(team, subject, text)
  return { team, subject }
}

app/api/inbound-email/route.ts:

import { sortEmail } from "@/lib/tickets"

export async function POST(request: Request) {
  const ticket = await sortEmail(await request.json())
  return ticket ? Response.json(ticket, { status: 201 }) : new Response(null, { status: 204 })
}

Error triage

Choose whether to ignore an error, send a notification, or page someone:

lib/error-triage.ts:

import { hunch } from "./hunch"
import { pagerduty } from "./pagerduty"
import { slack } from "./slack"

export async function triage(error: Error, { path, routeType }: { path: string; routeType: string }) {
  const verdict = await hunch.rate(
    {
      ignore: "known noise, expected in normal operation",
      notify: "worth a look during working hours",
      page: "users are impacted right now",
    },
    { given: { name: error.name, message: error.message, path, routeType } },
  )

  switch (verdict.level) {
    case "page":
      pagerduty.trigger(error)
      break
    case "notify":
      slack.post(error)
      break
  }
  return verdict.level
}

instrumentation.ts:

import type { Instrumentation } from "next"
import { triage } from "@/lib/error-triage"

export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
  await triage(error as Error, { path: request.path, routeType: context.routeType })
}

Enum coercion

Map imported text to a known status:

lib/orders.ts:

export const STATUSES = ["pending", "shipped", "delivered", "cancelled"] as const

export function importStatus(raw: string) {
  return hunch.pick(STATUSES, { given: raw, question: "which order status does this text describe?" })
}
await importStatus("sent it out tuesday??") // "shipped"

Moderation

lib/comments.ts:

export async function moderate(body: string) {
  const tone = await hunch.rate(["civil", "heated", "abusive"], {
    given: body,
    question: "how abusive is this comment?",
  })

  switch (tone.level) {
    case "civil":
      return "published"
    case "heated":
      return "held"
    case "abusive":
      return "rejected"
  }
}

export async function postComment(body: string) {
  const status = await moderate(body)
  db.prepare("INSERT INTO comments (body, status) VALUES (?, ?)").run(body, status)
  return status
}

Testing

Use the stub backend to supply answers without making API calls:

import { Stub } from "@carldaws/hunch"

hunch.backend = new Stub({ fraud: 0.95, team: "billing", mood: "calm" })

Use the question's key to supply its answer, or answer for calls outside hunch.decide. Stub values depend on the method:

  • chance and its levels: a probability or boolean.
  • pick: an option name or an object of probabilities.
  • rate: a level name or numeric position.

Missing answers throw unless you pass a fallback, as in new Stub({}, { fallback: 0.5 }). The stub records each call in calls for assertions. Here deliver posts to the inbound email route and rows reads a table:

test/inbound-email.test.ts:

test("spam is dropped without a ticket", async () => {
  hunch.backend = new Stub({ answer: "spam" })
  const response = await deliver({ subject: "You have WON", text: "claim your prize" })

  expect(response.status).toBe(204)
  expect(rows("tickets")).toEqual([])
})

Configuration

new Hunch({
  backend: new SystemOne({
    url: "https://openrouter.ai/api/alpha/decisions",
    apiKey: process.env.OPENROUTER_API_KEY, // a missing key throws when you ask
    model: "typesafe/jev-1.13",
    timeout: 5000,                          // milliseconds per attempt
    maxRetries: 2,                          // 429/5xx/timeouts, with backoff, honours Retry-After
  }),
  levels: { definitely: 0.99 },
})

Errors

APIError covers failures worth working around: timeouts, lost connections, rate limits, server errors, and answers the backend got wrong (InvalidAnswerError). AuthenticationError, ValidationError, and ConfigurationError mean your setup needs fixing, so they are not APIErrors.

Backends

SystemOne talks to any endpoint that speaks the System One decisions protocol of noul, choice, and score questions. Stub is for testing. A backend is any object with a decide({ state, questions }) method that returns { answers }.

License

MIT

About

Probabilistic control flow for TypeScript - powered by TypeSafe's Jev

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages