Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ Boolean questions answer as `noul`, a probability from 0 to 1 that the answer is
| Export | Description |
| --------------------------------- | ----------------------------------------------------------------------------------- |
| `evaluate(input, options?)` | Runs one evaluation. Returns `EvaluateResult`: decisions or a `FallbackResult`. |
| `systemOne(input, options?)` | Keyed decisions. Returns `answers` by question name, or the same fallback. |
| `choice`, `noul`, `score`, `box` | Build one keyed question. `box` locates a region and needs model `jev-preview`. |
| `image`, `Image.fromBase64` | PNG or JPEG bytes, a `Blob`, or base64. Other formats are rejected before send. |
| `withAssets` | Tag a template so interpolated images are sent. Also `withAssets.join`. |
| `createSystemOneAdapter(config?)` | Returns a `ProviderAdapter` for `runInference`. `config` is an `EvaluateConfig`. |
| `createSystemOneAdapterFactory` | Interchange `AdapterFactory` for `SIDECAR_ADAPTER_MANIFEST`; takes offering quirks. |
| `SYSTEM_ONE_PROVIDER` | The `"corbits-system-one"` provider id. |
Expand All @@ -82,14 +86,49 @@ Boolean questions answer as `noul`, a probability from 0 to 1 that the answer is

### Questions

| `type` | Fields | Decision fields |
| --------- | ------------------------------------------------------------------------------- | ------------------------------------------------ |
| `choice` | `instructions`, `criteria` (option name → description or `null`, 1–255 options) | `choice`, `probabilities`, `confidence` |
| `score` | `instructions`, `criteria` (2–10 descriptions) | `score`, `legend`, `probabilities`, `confidence` |
| `boolean` | `instructions`, optional `criteria: { true?, false? }` | `noul` |
| `noul` | Same as `boolean` | `noul` |
| `type` | Fields | Decision fields |
| --------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `choice` | `instructions`, `criteria` (option name → description or `null`, 1–255 options) | `choice`, `probabilities`, `confidence` |
| `score` | `instructions`, `criteria` (2–10 descriptions) | `score`, `legend`, `probabilities`, `confidence` |
| `boolean` | `instructions`, optional `criteria: { true?, false? }` | `noul` |
| `noul` | Same as `boolean` | `noul` |
| `box` | `instructions` describing what to locate. Use model `jev-preview`. | `box` (`top`, `bottom`, `left`, `right` as fractions), `confidence` |

Every question has an `id`, unique within the list.
Every list question has an `id`, unique within the list. `box` edges outside `[0, 1]` are rejected.

### Keyed questions

`systemOne` takes the same state and a record of questions built with `choice`, `noul`, `score`, and `box`. The names are the answer keys. Those helpers compile to the list `evaluate` already sends. The same fallback reasons apply, and those failures do not throw. The helpers are also exported from `@corbits/system-one/decisions`.

```ts
import { choice, noul, score, systemOne } from "@corbits/system-one";

const result = await systemOne({
state: { document: "I was charged twice." },
questions: {
category: choice("What is this?", { billing: null, technical: null }),
escalate: noul("Must this be escalated?"),
severity: score("How severe?", ["none", "cosmetic", "unusable"]),
},
});
```

On success, read `result.answers.category.choice` (plus `probabilities` and `confidence`), `result.answers.escalate.noul`, and `result.answers.severity.score`.

`image()` accepts a `Blob` or `Uint8Array`. `Image.fromBase64()` accepts base64 file bytes. Only PNG and JPEG are sent. Place an image in `state`, instructions, or criteria, or interpolate it with `withAssets` (and `withAssets.join` for a list). An image in an ordinary template string is rejected before the request. `assets` maps a name to an image for plain strings; every name must appear as its own token in the request. A request with any image is `multipart/form-data` and sends `TypeSafe-Danger-Zone-Images: true`. Otherwise the body stays JSON.

```ts
import { box, image, systemOne, withAssets } from "@corbits/system-one";

const photo = image(pngBytes);
const result = await systemOne({
state: withAssets`Returned item: ${photo}`,
questions: { damage: box("The damaged area") },
model: "jev-preview",
});
```

`providerOptions.systemOne.questions` accepts this keyed record as well as a question list.

### Config

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
"corbits-system-one-src": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./decisions": {
"corbits-system-one-src": "./src/decisions.ts",
"types": "./dist/decisions.d.ts",
"default": "./dist/decisions.js"
}
},
"publishConfig": {
Expand Down
68 changes: 68 additions & 0 deletions src/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,72 @@ describe("adapter factory", () => {
/model must not be empty/,
);
});

test("providerOptions.systemOne accepts keyed questions", () => {
const adapter = createSystemOneAdapter();
const request = adapter.buildRequest([], "jev-latest", {
providerOptions: {
systemOne: {
state: "ticket",
questions: {
category: {
type: "choice",
instructions: "What?",
criteria: { a: null },
},
},
},
},
});
const body = JSON.parse(request.body) as {
questions: Record<string, { type: string }>;
};
expect(body.questions["category"]?.type).toBe("choice");
});

test("parseJSONResponse rejects a box outside the image", () => {
const adapter = createSystemOneAdapter();
expect(() =>
adapter.parseJSONResponse(
JSON.stringify({
answers: {
region: {
type: "box",
box: { top: -0.1, bottom: 0.4, left: 0.2, right: 0.5 },
confidence: null,
},
},
}),
),
).toThrow(/outside \[0, 1\]/);
});

test("parseJSONResponse rejects an inverted box", () => {
const adapter = createSystemOneAdapter();
const reject = (box: {
top: number;
bottom: number;
left: number;
right: number;
}) =>
adapter.parseJSONResponse(
JSON.stringify({
answers: {
region: { type: "box", box, confidence: null },
},
}),
);
expect(() =>
reject({ top: 0.8, bottom: 0.1, left: 0.2, right: 0.5 }),
).toThrow(/not below/);
expect(() =>
reject({ top: 0.2, bottom: 0.2, left: 0.1, right: 0.4 }),
).toThrow(/not below/);
expect(() =>
reject({ top: 0.1, bottom: 0.4, left: 0.9, right: 0.2 }),
).toThrow(/not left of/);
expect(() =>
reject({ top: 0.1, bottom: 0.4, left: 0.5, right: 0.5 }),
).toThrow(/not left of/);
});
});
35 changes: 33 additions & 2 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import type {
import { resolveEndpoint } from "./config.js";
import {
Decision,
DecisionQuestionRecord,
toDecision,
toWireQuestions,
WireResponseBody,
boxEdgesOutOfRange,
EndpointConfig,
QuestionList,
State,
Expand Down Expand Up @@ -51,9 +53,11 @@ export const SYSTEM_ONE_PROVIDER = "corbits-system-one";
// `state` replaces the transcript-derived default; `questions` replaces the
// single-question default. Both are validated, never cast — `QuestionList`
// carries the same non-empty/unique-id contract as `EvaluateInput`.
const QuestionsInput = QuestionList.or(DecisionQuestionRecord);

const SystemOneProviderOptions = type({
"state?": State,
"questions?": QuestionList,
"questions?": QuestionsInput,
"+": "reject",
});

Expand Down Expand Up @@ -85,6 +89,24 @@ const DEFAULT_QUESTIONS: QuestionList = [
},
];

function asQuestionList(
questions: QuestionList | DecisionQuestionRecord,
): QuestionList {
if (Array.isArray(questions)) return questions;
const compiled = Object.entries(questions).map(([id, question]) => ({
id,
...question,
}));
const parsed = QuestionList(compiled);
if (parsed instanceof type.errors) {
throw new ProtocolMismatchError(
`${SYSTEM_ONE_PROVIDER} buildRequest: invalid providerOptions.systemOne questions: ${parsed.summary}`,
questions,
);
}
return parsed;
}

function transcriptText(turns: ConversationTurn[]): string {
const texts: string[] = [];
for (const turn of turns) {
Expand Down Expand Up @@ -142,6 +164,13 @@ function parseDecisions(
);
}
try {
if (answer.type === "box") {
if (answer.box === undefined) {
throw new Error(`answer "${id}" is missing box`);
}
const violation = boxEdgesOutOfRange(id, answer.box);
if (violation !== undefined) throw new Error(violation);
}
return toDecision(id, answer);
} catch (cause) {
throw new ProtocolMismatchError(
Expand Down Expand Up @@ -187,7 +216,9 @@ function createSystemOneProviderAdapter(
);
}
if (overrides.state !== undefined) state = overrides.state;
if (overrides.questions !== undefined) questions = overrides.questions;
if (overrides.questions !== undefined) {
questions = asQuestionList(overrides.questions);
}
}
const requestModel = model || endpoint.model;
const headers: Record<string, string> = {
Expand Down
Loading
Loading