diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd746b..21e7953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added `chooseMany()` for independent questions with common context and optional images. + ## 0.7.0 - 2026-09-28 - Reduced llama.cpp scoring requests with batched tokenization and overlapping vision checks. diff --git a/README.md b/README.md index 09c611d..7388c88 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,32 @@ Supported media types are PNG, JPEG, and WebP. llama.cpp image inputs currently The MCP server accepts image file paths through `imagePaths` in `labels` mode. Paths are resolved from the server's working directory by default; set `CHOOSEKIT_IMAGE_ROOT` to use another root. Every image must remain within that root and be a PNG, JPEG, or WebP file. With OpenRouter, the image contents are sent to the remote service. +## Multiple questions + +Use `chooseMany()` to ask several independent questions about the same context: + +```ts +import { chooseMany } from "choosekit"; + +const decisions = await chooseMany(choose, { + context: "A payout failed three days in a row. Payroll is due today.", + questions: { + route: { + question: "Which team should handle this?", + choices: { billing: "Payments and payouts", technical: "Bugs and API errors" }, + }, + urgency: { + question: "How urgent is this?", + choices: { high: "Needs attention today", low: "Can wait" }, + }, + }, +}); + +console.log(decisions.route.choice, decisions.urgency.choice); +``` + +Each question calls `choose()` concurrently and returns its own decision and, when available, token and request counts. Supplied images are sent with each call. If one call fails, the group rejects while calls already in progress continue. + ## Scoring modes | Mode | Candidate representation | Use when | diff --git a/src/choose-many.ts b/src/choose-many.ts new file mode 100644 index 0000000..73abbd4 --- /dev/null +++ b/src/choose-many.ts @@ -0,0 +1,38 @@ +import type { + ChoiceKey, Chooser, Choices, Decision, ImageInput, +} from "./types.js"; +import { isRecord } from "./validation.js"; + +type Questions = Readonly>; + +type Request = { + readonly context: string; + readonly questions: Q; + readonly images?: readonly ImageInput[]; + readonly signal?: AbortSignal; +}; + +type Results = Readonly<{ + [K in keyof Q]: Decision>; +}>; + +export async function chooseMany( + choose: Chooser, request: Request, +): Promise> { + if (typeof choose !== "function") throw new TypeError("choose must be a function."); + const { context, questions, images, signal } = request; + signal?.throwIfAborted(); + if (!isRecord(questions) || Object.getOwnPropertySymbols(questions).length !== 0) { + throw new TypeError("questions must be an object of named questions."); + } + const entries = Object.entries(questions); + if (entries.length === 0) throw new TypeError("Provide at least one question."); + + const decisions = await Promise.all(entries.map(async ([name, { question, choices }]) => + [name, await choose({ + context, question, choices, + ...(images === undefined ? {} : { images }), + ...(signal === undefined ? {} : { signal }), + })] as const)); + return Object.freeze(Object.fromEntries(decisions)) as Results; +} diff --git a/src/index.ts b/src/index.ts index e3a00fd..5f4d159 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { createFormattedChooser } from "./internal-chooser.js"; +export { chooseMany } from "./choose-many.js"; import type { Chooser, ChooserOptions, Scorer } from "./types.js"; export type { diff --git a/tests/choose-many.test.mjs b/tests/choose-many.test.mjs new file mode 100644 index 0000000..7fa1805 --- /dev/null +++ b/tests/choose-many.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { chooseMany, createChooser } from "../dist/esm/index.js"; + +test("returns separate decisions for named questions with shared inputs", async () => { + const controller = new AbortController(); + const images = [{ mediaType: "image/png", base64: "aW1hZ2U=" }]; + const seen = []; + const choose = createChooser(({ prompt, candidates, images: receivedImages, signal }) => { + seen.push({ prompt, receivedImages, signal }); + return { + logprobs: candidates.map((_, index) => -index - 1), + usage: { + promptTokens: prompt.includes("Which team?") ? 10 : 20, + cachedTokens: 2, completionTokens: 1, requests: 1, + }, + }; + }); + const decisions = await chooseMany(choose, { + context: "A payout failed.", images, signal: controller.signal, + questions: { + route: { + question: "Which team?", + choices: { billing: "Payments and refunds", technical: "Bugs and API errors" }, + }, + urgency: { + question: "How urgent?", + choices: { high: "Needs attention today", low: "Can wait" }, + }, + }, + }); + + assert.deepEqual(Object.keys(decisions), ["route", "urgency"]); + assert.equal(decisions.route.choice, "billing"); + assert.equal(decisions.urgency.choice, "high"); + assert.deepEqual(Object.keys(decisions.route.distribution), ["billing", "technical"]); + assert.deepEqual(Object.keys(decisions.urgency.distribution), ["high", "low"]); + assert.deepEqual(decisions.route.usage, { + promptTokens: 10, cachedTokens: 2, completionTokens: 1, requests: 1, + }); + assert.deepEqual(decisions.urgency.usage, { + promptTokens: 20, cachedTokens: 2, completionTokens: 1, requests: 1, + }); + assert.ok(Object.isFrozen(decisions)); + assert.equal(seen.length, 2); + for (const call of seen) { + assert.ok(call.prompt.startsWith("A payout failed.")); + assert.deepEqual(call.receivedImages, images); + assert.equal(call.signal, controller.signal); + } +}); + +test("starts questions concurrently and rejects when one fails", async () => { + const failure = new Error("Backend failed"); + const started = []; + let finishOther; + let reportCompletion; + const completed = new Promise((resolve) => { reportCompletion = resolve; }); + const choose = async ({ question }) => { + started.push(question); + if (question === "First?") throw failure; + await new Promise((resolve) => { finishOther = resolve; }); + reportCompletion(); + return { choice: "yes" }; + }; + const pending = chooseMany(choose, { + context: "Shared.", + questions: { + first: { question: "First?", choices: { yes: "Yes", no: "No" } }, + second: { question: "Second?", choices: { yes: "Yes", no: "No" } }, + }, + }); + await assert.rejects(pending, (error) => error === failure); + assert.deepEqual(started, ["First?", "Second?"]); + finishOther(); + await completed; +}); + +test("rejects an empty question set or an aborted signal before starting work", async () => { + let called = false; + const choose = async () => { called = true; throw new Error("Unexpected call"); }; + await assert.rejects(chooseMany(choose, { context: "", questions: {} }), TypeError); + const signal = AbortSignal.abort(); + await assert.rejects(chooseMany(choose, { + context: "", signal, + questions: { one: { question: "One?", choices: { yes: "Yes", no: "No" } } }, + }), (error) => error === signal.reason); + assert.equal(called, false); +}); + +test("passes a shared signal to every in-flight question", async () => { + const controller = new AbortController(); + const reason = new Error("Cancelled"); + const received = []; + const choose = ({ signal }) => new Promise((_, reject) => { + received.push(signal); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + const pending = chooseMany(choose, { + context: "", signal: controller.signal, + questions: { + first: { question: "First?", choices: { yes: "Yes", no: "No" } }, + second: { question: "Second?", choices: { yes: "Yes", no: "No" } }, + }, + }); + assert.deepEqual(received, [controller.signal, controller.signal]); + controller.abort(reason); + await assert.rejects(pending, (error) => error === reason); +}); diff --git a/tests/types/api.cts b/tests/types/api.cts index 19e56bf..a3fb3f6 100644 --- a/tests/types/api.cts +++ b/tests/types/api.cts @@ -1,4 +1,4 @@ -import { createChooser, type Scorer } from "choosekit"; +import { chooseMany, createChooser, type Scorer } from "choosekit"; import { fromLlamaCpp } from "choosekit/llama-cpp"; import { fromOllama } from "choosekit/ollama"; import { fromOpenRouter } from "choosekit/openrouter"; @@ -8,3 +8,9 @@ void choose({ context: "", question: "Next?", choices: { test: "Test", done: "Do void fromLlamaCpp({ baseURL: "http://127.0.0.1:8080" }); void fromOllama({ model: "test-model" }); void fromOpenRouter({ apiKey: "test-key", model: "test/model" }); +void chooseMany(choose, { + context: "", questions: { route: { question: "Next?", choices: { test: "Test", done: "Done" } } }, +}).then((decisions) => { + const key: "test" | "done" = decisions.route.choice; + void key; +}); diff --git a/tests/types/api.mts b/tests/types/api.mts index 59996bf..e15f41e 100644 --- a/tests/types/api.mts +++ b/tests/types/api.mts @@ -1,4 +1,4 @@ -import { createChooser, type Scorer, type Decision, type ImageInput } from "choosekit"; +import { chooseMany, createChooser, type Scorer, type Decision, type ImageInput } from "choosekit"; import { fromLlamaCpp } from "choosekit/llama-cpp"; import { fromOllama } from "choosekit/ollama"; import { fromOpenRouter } from "choosekit/openrouter"; @@ -54,3 +54,25 @@ const cached: number | null | undefined = decision.usage?.cachedTokens; void cached; // @ts-expect-error No calibrated correctness confidence is claimed. decision.confidence; + +const decisions = await chooseMany(choose, { + context: "A payout failed.", + images: [image], + questions: { + route: { question: "Which team?", choices: { billing: "Payments", technical: "API bugs" } }, + urgency: { question: "How urgent?", choices: { high: "Today", low: "Can wait" } }, + }, +}); +const route: "billing" | "technical" = decisions.route.choice; +const urgency: "high" | "low" = decisions.urgency.choice; +const routeProbability: number = decisions.route.distribution.billing; +void route; void urgency; void routeProbability; +// @ts-expect-error Route choices do not include urgency choices. +decisions.route.distribution.high; +// @ts-expect-error Urgency choices do not include route choices. +const wrongUrgency: "billing" = decisions.urgency.choice; +void wrongUrgency; +// @ts-expect-error Each named question requires a question. +void chooseMany(choose, { context: "", questions: { route: { choices: { a: "A", b: "B" } } } }); +// @ts-expect-error Choice descriptions must be strings. +void chooseMany(choose, { context: "", questions: { route: { question: "?", choices: { a: 1, b: "B" } } } });