@mccaskie-labs/workbench-ai 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +18 -0
- package/dist/ai.d.ts +1 -5
- package/dist/ai.js +4 -2
- package/dist/cli.js +22 -0
- package/dist/fake.d.ts +32 -0
- package/dist/fake.js +49 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/intake-definition.d.ts +88 -0
- package/dist/intake-definition.js +420 -0
- package/dist/intake-generate.d.ts +9 -0
- package/dist/intake-generate.js +149 -0
- package/dist/intake.d.ts +102 -0
- package/dist/intake.js +134 -0
- package/dist/replies.d.ts +19 -0
- package/dist/reply-ui.d.ts +8 -1
- package/dist/reply-ui.js +9 -2
- package/dist/types.d.ts +3 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-ai
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Message intake: `ai.intake()` reads a social media message (a screenshot, pasted text or both) into one of the project's intake types through the Gateway's `POST /v1/intake/read`. It returns the reply drafter's `IntakeResult` (snake_case, as the Gateway sends it) typed by the project's own types: the type, its fields, what is missing or reads two ways, and `needs_human_check`, so `ai.reply` takes it unchanged. `npx workbench-ai intake generate` checks `workbench/intake/*.json` with the Gateway's own rules and writes `src/intake.generated.ts` (`--check` for Checks). With `save_input: project` it keeps the image and text in the project's own `INTAKE_STORE` bucket; Workbench keeps only the call's facts. `createFakeAi` answers from each type's examples. The `intake` manifest carries its client questions.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @mccaskie-labs/workbench-config@0.4.0
|
|
13
|
+
|
|
14
|
+
## 0.4.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- `REPLY_SCRIPT` now defines its own `__name`, so Copy and Write it again work when the app is bundled by Wrangler (esbuild's keepNames wrapped its functions in `__name(...)`, which the browser doesn't have).
|
|
19
|
+
|
|
3
20
|
## 0.4.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -54,6 +54,24 @@ npx workbench-ai pick <name> <round>-<draft> [--licence "<the owner's words>"] [
|
|
|
54
54
|
|
|
55
55
|
`drafts` writes the drafts and a preview page to `workbench/<ai|images|image-search>/drafts/<name>/` (keep it out of git), prints what the round cost, and stops after three rounds. `pick` keeps one in `workbench/<tool>/<name>.json`, and for a picture writes `public/images/<name>.<ext>`. A found picture needs `--licence`. Exit codes: 2 for a bad command, 3 for no development key or Gateway address, 4 after the last round.
|
|
56
56
|
|
|
57
|
+
## Message intake: `ai.intake()`
|
|
58
|
+
|
|
59
|
+
Reads one social media message (a screenshot, pasted text or both) into one of the project's intake types (`docs/message-intake.md`). Keep one file per type in `workbench/intake/<name>.json`, then:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npx workbench-ai intake generate # checks the files, writes src/intake.generated.ts
|
|
63
|
+
npx workbench-ai intake generate --check # in Checks: fails when the file is stale
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { intakeTypes } from "./intake.generated";
|
|
68
|
+
|
|
69
|
+
const r = await ai.intake({ types: intakeTypes, text, image: { bytes, mimeType: "image/png" }, timeZone: "Asia/Manila", job: "intake.quote-request", subject: `user:${user.id}`, ref: `enquiry:${id}` });
|
|
70
|
+
if (r.type === "venue-quote-request" && !r.needs_human_check) save(r.data);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Workbench keeps only the call's facts, never the image or the text. With `"save_input": "project"` in `workbench/intake.json`, pass `store: env.INTAKE_STORE` and the image and text are written to the project's own bucket; `r.saved` says where. The result is the reply drafter's `IntakeResult` (snake_case, as the Gateway sends it), typed by your types, so it goes straight into `ai.reply`'s `incoming.intake`.
|
|
74
|
+
|
|
57
75
|
## Reply drafts: `ai.reply`
|
|
58
76
|
|
|
59
77
|
A ready-to-paste reply to a customer's message, from facts your code worked out and a reply guide (docs/capabilities/04-reply-drafter.md). The rule: **facts come from data, words from the AI.** The model places every fact as a token (`{{packages.0.price}}`); the package fills it with workbench-country (₱8,500, Saturday, December 12) and then checks the finished text. A price, date, number or address the model typed itself fails the draft: it is written once more, and if it still fails it comes back with `needs_human_check: true` and the reasons. Nothing is ever sent: a person copies it.
|
package/dist/ai.d.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { type GatewayMeta } from "./capture.ts";
|
|
3
|
-
import type { Ai, AiOptions, TextResult } from "./types.ts";
|
|
1
|
+
import type { Ai, AiOptions } from "./types.ts";
|
|
4
2
|
/**
|
|
5
3
|
* Claude through the Workbench Gateway (docs/spec.md §3). The app holds only
|
|
6
4
|
* its Gateway key; the Gateway resolves aliases, checks the budget, retries,
|
|
7
5
|
* and meters every call.
|
|
8
6
|
*/
|
|
9
7
|
export declare function createAi(opts: AiOptions): Ai;
|
|
10
|
-
/** Turns a Message into a result, or throws: a refusal or a truncation is never a success (audit F18). */
|
|
11
|
-
export declare function toTextResult(message: Anthropic.Message, meta: GatewayMeta): TextResult;
|
|
12
8
|
/**
|
|
13
9
|
* Top-level fields the model left empty (`""`, `null`, `[]`), except those it
|
|
14
10
|
* may lack. Structured outputs guarantees the keys are present; this catches
|
package/dist/ai.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createCapture, isGatewayMeta, REQUEST_ID_HEADER } from "./capture.js";
|
|
|
4
4
|
import { errorFromGateway, WorkbenchAiError } from "./errors.js";
|
|
5
5
|
import { gatewayFetch } from "./http.js";
|
|
6
6
|
import { createImages } from "./images.js";
|
|
7
|
+
import { createIntake } from "./intake.js";
|
|
7
8
|
import { draftReply } from "./replies.js";
|
|
8
9
|
import { SPELLING_NAMES, VOICE_NAMES } from "./writing.js";
|
|
9
10
|
const KEY_PREFIX = {
|
|
@@ -202,9 +203,10 @@ export function createAi(opts) {
|
|
|
202
203
|
return value;
|
|
203
204
|
}
|
|
204
205
|
const images = createImages({ url, key: opts.key, env, fetch: baseFetch });
|
|
206
|
+
const intake = createIntake({ url, key: opts.key, env, fetch: baseFetch });
|
|
205
207
|
const replyWithTemplate = (req) => draftReply(structured, opts.replyGuides, req);
|
|
206
208
|
const reply = async (req) => (await replyWithTemplate(req)).draft;
|
|
207
|
-
return { anthropic, text, structured, stream, countTokens, budget, images, reply, replyWithTemplate };
|
|
209
|
+
return { anthropic, text, structured, stream, countTokens, budget, images, intake, reply, replyWithTemplate };
|
|
208
210
|
}
|
|
209
211
|
function params(req, defaultMax) {
|
|
210
212
|
const body = {
|
|
@@ -242,7 +244,7 @@ function metaOf(message, cap) {
|
|
|
242
244
|
});
|
|
243
245
|
}
|
|
244
246
|
/** Turns a Message into a result, or throws: a refusal or a truncation is never a success (audit F18). */
|
|
245
|
-
|
|
247
|
+
function toTextResult(message, meta) {
|
|
246
248
|
const base = { requestId: meta.request_id, model: meta.model };
|
|
247
249
|
if (message.stop_reason === "refusal" || meta.refused === true) {
|
|
248
250
|
const category = message.stop_details?.category ?? null;
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// Keeps that draft: workbench/<tool>/<name>.json, and for a picture the
|
|
12
12
|
// file in public/images/. A found picture needs --licence: what the owner
|
|
13
13
|
// said gives the site the right to use it.
|
|
14
|
+
// workbench-ai intake generate [--check] [dir]
|
|
15
|
+
// Checks workbench/intake/*.json with the Gateway's rules and writes
|
|
16
|
+
// src/intake.generated.ts (docs/message-intake.md §3). --check only
|
|
17
|
+
// compares, for the project's Checks.
|
|
14
18
|
// workbench-ai replies generate [--check] [dir]
|
|
15
19
|
// workbench-ai replies drafts <name> [--languages en,taglish] [--facts <file>] [dir]
|
|
16
20
|
// workbench-ai replies pick <name> <round>-<draft> [dir]
|
|
@@ -20,11 +24,13 @@ import { dirname, join, resolve } from "node:path";
|
|
|
20
24
|
import { fileURLToPath } from "node:url";
|
|
21
25
|
import { ASPECT_RATIOS, CONTENT_KINDS, draftContent } from "./content.js";
|
|
22
26
|
import { generate, pickSample, readGuides, sampleDrafts } from "./replies-gen.js";
|
|
27
|
+
import { IntakeGenerateError, intakeGenerate } from "./intake-generate.js";
|
|
23
28
|
/** Rounds of drafts per piece (the offers' drafts.max_rounds), counted in the drafts folder's rounds.json. */
|
|
24
29
|
export const MAX_ROUNDS = 3;
|
|
25
30
|
const USAGE = [
|
|
26
31
|
'usage: workbench-ai drafts text|image|search <name> --about "<what it is for>" [--count 3] [--aspect 3:2] [--spelling en-AU] [dir]',
|
|
27
32
|
' workbench-ai pick <name> <round>-<draft> [--licence "<the owner\'s words>"] [--replace] [dir]',
|
|
33
|
+
" workbench-ai intake generate [--check] [dir]",
|
|
28
34
|
" workbench-ai replies generate [--check] [dir]",
|
|
29
35
|
" workbench-ai replies drafts <name> [--languages en,taglish] [--facts <file>] [dir]",
|
|
30
36
|
" workbench-ai replies pick <name> <round>-<draft> [dir]",
|
|
@@ -275,6 +281,22 @@ export async function replies(argv) {
|
|
|
275
281
|
}
|
|
276
282
|
export async function main(argv) {
|
|
277
283
|
try {
|
|
284
|
+
if (argv[0] === "intake") {
|
|
285
|
+
const rest = argv.slice(1);
|
|
286
|
+
const check = rest.includes("--check");
|
|
287
|
+
const pos = rest.filter((a) => a !== "--check");
|
|
288
|
+
if (pos[0] !== "generate" || pos.length > 2 || pos.some((a) => a.startsWith("--")))
|
|
289
|
+
throw new Stop(USAGE, 2);
|
|
290
|
+
try {
|
|
291
|
+
console.log(intakeGenerate(resolve(pos[1] ?? "."), check).join("\n"));
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
if (e instanceof IntakeGenerateError)
|
|
295
|
+
throw new Stop(e.message, 1);
|
|
296
|
+
throw e;
|
|
297
|
+
}
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
278
300
|
if (argv[0] === "replies") {
|
|
279
301
|
console.log((await replies(argv.slice(1))).join("\n"));
|
|
280
302
|
return 0;
|
package/dist/fake.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface FakeCall {
|
|
|
34
34
|
/** The Workbench voice and spelling named (X-Workbench-Voice, X-Workbench-Spelling). */
|
|
35
35
|
voice: string | null;
|
|
36
36
|
spelling: string | null;
|
|
37
|
+
/** The app's own reference (X-Workbench-Ref), sent by intake. */
|
|
38
|
+
ref: string | null;
|
|
37
39
|
/** The parsed JSON body (Anthropic Messages request), if any. */
|
|
38
40
|
body: Record<string, unknown> | null;
|
|
39
41
|
stream: boolean;
|
|
@@ -75,7 +77,37 @@ export interface FakeScript {
|
|
|
75
77
|
error?: FakeError;
|
|
76
78
|
};
|
|
77
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* What `intake` gets back. By default the fake reads the message as the type
|
|
82
|
+
* whose example has the same text (else the first type asked for) and answers
|
|
83
|
+
* with that example's data, so tests run on the project's own examples.
|
|
84
|
+
*/
|
|
85
|
+
intake?: {
|
|
86
|
+
answer?: FakeIntakeAnswer | ((body: Record<string, unknown>) => FakeIntakeAnswer);
|
|
87
|
+
error?: FakeError;
|
|
88
|
+
};
|
|
78
89
|
}
|
|
90
|
+
/** What the fake `/v1/intake/read` answers: Gateway fields (snake_case) laid over the default reading. */
|
|
91
|
+
export type FakeIntakeAnswer = Partial<{
|
|
92
|
+
type: string;
|
|
93
|
+
type_version: number | null;
|
|
94
|
+
confidence: number;
|
|
95
|
+
data: Record<string, unknown> | null;
|
|
96
|
+
missing: string[];
|
|
97
|
+
ambiguous: {
|
|
98
|
+
field: string;
|
|
99
|
+
reason: string;
|
|
100
|
+
options: unknown[];
|
|
101
|
+
}[];
|
|
102
|
+
assumptions: {
|
|
103
|
+
field: string;
|
|
104
|
+
note: string;
|
|
105
|
+
}[];
|
|
106
|
+
raw_text: string;
|
|
107
|
+
languages: string[];
|
|
108
|
+
needs_human_check: boolean;
|
|
109
|
+
check_reasons: string[];
|
|
110
|
+
}>;
|
|
79
111
|
export interface FakeError {
|
|
80
112
|
status: number;
|
|
81
113
|
code: string;
|
package/dist/fake.js
CHANGED
|
@@ -63,6 +63,52 @@ function fakeSearch(script, body, requestId) {
|
|
|
63
63
|
request_id: requestId,
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
|
+
function fakeIntake(script, body, requestId) {
|
|
67
|
+
const i = script.intake ?? {};
|
|
68
|
+
if (i.error)
|
|
69
|
+
return v1Error(i.error.status, requestId, i.error.code, i.error.message ?? i.error.code);
|
|
70
|
+
const types = (Array.isArray(body?.["types"]) ? body["types"] : []);
|
|
71
|
+
if (types.length === 0)
|
|
72
|
+
return v1Error(400, requestId, "invalid_request", "types must list 1 to 20 intake type definitions");
|
|
73
|
+
const only = Array.isArray(body?.["only"]) ? body["only"] : types.map((t) => t.name);
|
|
74
|
+
const text = typeof body?.["text"] === "string" ? body["text"] : "";
|
|
75
|
+
const asked = types.filter((t) => only.includes(t.name));
|
|
76
|
+
const matched = asked.find((t) => t.examples?.some((e) => e.text === text));
|
|
77
|
+
const t = matched ?? asked[0];
|
|
78
|
+
const example = t.examples?.find((e) => e.text === text) ?? t.examples?.[0];
|
|
79
|
+
const data = example?.data ?? {};
|
|
80
|
+
const missing = (t.schema?.required ?? []).filter((f) => !(f in data));
|
|
81
|
+
const base = {
|
|
82
|
+
type: t.name,
|
|
83
|
+
type_version: t.version,
|
|
84
|
+
confidence: 0.95,
|
|
85
|
+
candidates: [{ type: t.name, confidence: 0.95 }],
|
|
86
|
+
data,
|
|
87
|
+
missing,
|
|
88
|
+
ambiguous: [],
|
|
89
|
+
assumptions: [],
|
|
90
|
+
raw_text: text,
|
|
91
|
+
languages: ["en"],
|
|
92
|
+
needs_human_check: missing.length > 0,
|
|
93
|
+
check_reasons: missing.length > 0 ? ["missing_required"] : [],
|
|
94
|
+
};
|
|
95
|
+
const over = typeof i.answer === "function" ? i.answer(body ?? {}) : (i.answer ?? {});
|
|
96
|
+
const answer = { ...base, ...over };
|
|
97
|
+
const cost = 0.0089;
|
|
98
|
+
return json(200, requestId, {
|
|
99
|
+
...answer,
|
|
100
|
+
workbench: {
|
|
101
|
+
request_id: requestId,
|
|
102
|
+
model: ALIASES.get("claude-default") ?? "claude-default",
|
|
103
|
+
alias: "claude-default",
|
|
104
|
+
cost_usd: cost,
|
|
105
|
+
attempts: 1,
|
|
106
|
+
kept: "facts_only",
|
|
107
|
+
intake_prompt_version: "fake",
|
|
108
|
+
receipt: { request_id: requestId, type: answer.type, type_version: answer.type_version, confidence: answer.confidence, needs_human_check: answer.needs_human_check, cost_usd: cost },
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
66
112
|
export function createFakeAi(script = {}) {
|
|
67
113
|
const env = script.env ?? "development";
|
|
68
114
|
const { fetch, calls } = fakeGateway(script);
|
|
@@ -93,6 +139,7 @@ export function fakeGateway(script = {}) {
|
|
|
93
139
|
keep: req.headers.get("x-workbench-keep")?.toLowerCase() !== "no",
|
|
94
140
|
voice: req.headers.get("x-workbench-voice"),
|
|
95
141
|
spelling: req.headers.get("x-workbench-spelling"),
|
|
142
|
+
ref: req.headers.get("x-workbench-ref"),
|
|
96
143
|
body,
|
|
97
144
|
stream: body?.["stream"] === true,
|
|
98
145
|
};
|
|
@@ -116,6 +163,8 @@ export function fakeGateway(script = {}) {
|
|
|
116
163
|
return fakeImage(script, body, requestId);
|
|
117
164
|
if (url.pathname === "/v1/images/search")
|
|
118
165
|
return fakeSearch(script, body, requestId);
|
|
166
|
+
if (url.pathname === "/v1/intake/read")
|
|
167
|
+
return fakeIntake(script, body, requestId);
|
|
119
168
|
if (url.pathname.startsWith("/anthropic/")) {
|
|
120
169
|
// As the Gateway does: an unknown name is refused, and a spelling is read in any case.
|
|
121
170
|
const allowed = capabilities.ai.allowed;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createAi, missingRequired } from "./ai.ts";
|
|
2
|
-
export { createFakeAi, fakeGateway, type FakeScript, type FakeReply, type FakeCall, type FakeAi } from "./fake.ts";
|
|
2
|
+
export { createFakeAi, fakeGateway, type FakeScript, type FakeReply, type FakeCall, type FakeAi, type FakeIntakeAnswer, type FakeError, type ReplyFor } from "./fake.ts";
|
|
3
|
+
export { defineIntakeTypes, type AiIntake, type IntakeCheckReason, type IntakeDefinition, type IntakeImageType, type IntakeRequest, type IntakeReading, type IntakeSaved, type IntakeTyped, type IntakeSaveInput, type IntakeStore, type IntakeTypes, } from "./intake.ts";
|
|
3
4
|
export { cachedSystem, cachedBlocks, provenance } from "./helpers.ts";
|
|
4
5
|
export { WorkbenchAiError, type AiErrorCode, type AiErrorDetails } from "./errors.ts";
|
|
5
6
|
export { REQUEST_ID_HEADER, type GatewayMeta } from "./capture.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createAi, missingRequired } from "./ai.js";
|
|
2
2
|
export { createFakeAi, fakeGateway } from "./fake.js";
|
|
3
|
+
export { defineIntakeTypes, } from "./intake.js";
|
|
3
4
|
export { cachedSystem, cachedBlocks, provenance } from "./helpers.js";
|
|
4
5
|
export { WorkbenchAiError } from "./errors.js";
|
|
5
6
|
export { REQUEST_ID_HEADER } from "./capture.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
type Json = Record<string, unknown>;
|
|
2
|
+
export declare const intakeLimits: {
|
|
3
|
+
types: number;
|
|
4
|
+
definitionBytes: number;
|
|
5
|
+
allTypesBytes: number;
|
|
6
|
+
properties: number;
|
|
7
|
+
objectArrayItems: number;
|
|
8
|
+
examples: number;
|
|
9
|
+
notThis: number;
|
|
10
|
+
glossary: number;
|
|
11
|
+
};
|
|
12
|
+
declare const PERSONAL_KINDS: readonly ["name", "handle", "phone", "email", "address", "photo"];
|
|
13
|
+
type PersonalKind = (typeof PERSONAL_KINDS)[number];
|
|
14
|
+
declare const STRING_FORMATS: readonly ["date", "date-time", "email", "phone", "uri"];
|
|
15
|
+
type StringFormat = (typeof STRING_FORMATS)[number];
|
|
16
|
+
/** One checked scalar property. */
|
|
17
|
+
export interface ScalarProp {
|
|
18
|
+
kind: "string" | "integer" | "number" | "boolean";
|
|
19
|
+
description?: string;
|
|
20
|
+
enum?: string[];
|
|
21
|
+
maxLength?: number;
|
|
22
|
+
format?: StringFormat;
|
|
23
|
+
minimum?: number;
|
|
24
|
+
maximum?: number;
|
|
25
|
+
personal?: PersonalKind;
|
|
26
|
+
}
|
|
27
|
+
export interface ScalarArrayProp {
|
|
28
|
+
kind: "array";
|
|
29
|
+
items: ScalarProp;
|
|
30
|
+
maxItems?: number;
|
|
31
|
+
description?: string;
|
|
32
|
+
personal?: PersonalKind;
|
|
33
|
+
}
|
|
34
|
+
interface ObjectProp {
|
|
35
|
+
kind: "object";
|
|
36
|
+
properties: Record<string, ScalarProp | ScalarArrayProp>;
|
|
37
|
+
required: string[];
|
|
38
|
+
description?: string;
|
|
39
|
+
personal?: PersonalKind;
|
|
40
|
+
}
|
|
41
|
+
interface ObjectArrayProp {
|
|
42
|
+
kind: "object_array";
|
|
43
|
+
items: {
|
|
44
|
+
properties: Record<string, ScalarProp>;
|
|
45
|
+
required: string[];
|
|
46
|
+
};
|
|
47
|
+
maxItems: number;
|
|
48
|
+
description?: string;
|
|
49
|
+
personal?: PersonalKind;
|
|
50
|
+
}
|
|
51
|
+
export type Prop = ScalarProp | ScalarArrayProp | ObjectProp | ObjectArrayProp;
|
|
52
|
+
interface IntakeExample {
|
|
53
|
+
received_at?: string;
|
|
54
|
+
text: string;
|
|
55
|
+
data: Json;
|
|
56
|
+
}
|
|
57
|
+
/** A checked intake type. `source` is the definition as sent, `bytes` its canonical JSON. */
|
|
58
|
+
export interface IntakeType {
|
|
59
|
+
name: string;
|
|
60
|
+
version: number;
|
|
61
|
+
description: string;
|
|
62
|
+
not_this: string[];
|
|
63
|
+
glossary: Record<string, string>;
|
|
64
|
+
examples: IntakeExample[];
|
|
65
|
+
properties: Record<string, Prop>;
|
|
66
|
+
required: string[];
|
|
67
|
+
confidence_floor: number;
|
|
68
|
+
model: "claude-default" | "claude-fast";
|
|
69
|
+
/** The schema as sent, for the prompt (the model reads its descriptions). */
|
|
70
|
+
schema: Json;
|
|
71
|
+
}
|
|
72
|
+
/** A definition that breaks §3, with the type and the JSON path in its message. */
|
|
73
|
+
export declare class IntakeTypeProblem extends Error {
|
|
74
|
+
}
|
|
75
|
+
/** Checks one definition. Throws IntakeTypeProblem with the type and the JSON path. */
|
|
76
|
+
export declare function checkDefinition(def: unknown, index: number): IntakeType;
|
|
77
|
+
export interface DataError {
|
|
78
|
+
/** The JSON path inside data, e.g. guest_count.min. "" for data itself. */
|
|
79
|
+
path: string;
|
|
80
|
+
/** The top-level field it belongs to, or "" for data itself. */
|
|
81
|
+
field: string;
|
|
82
|
+
message: string;
|
|
83
|
+
}
|
|
84
|
+
/** Checks data against a type's schema: every rule the structured-output schema can't hold (§4). Top-level required fields are not checked here: missing is worked out separately. */
|
|
85
|
+
export declare function validateData(t: IntakeType, data: unknown, opts?: {
|
|
86
|
+
requireTop?: boolean;
|
|
87
|
+
}): DataError[];
|
|
88
|
+
export {};
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// Intake type definitions (docs/message-intake.md §3): what a project sends in
|
|
2
|
+
// `types` with every POST /v1/intake/read. A project keeps one file per type in
|
|
3
|
+
// its own repo. This file checks one definition and validates data against it.
|
|
4
|
+
//
|
|
5
|
+
// It has no imports, and the same file is in the workbench-ai package
|
|
6
|
+
// (packages/ai/src/intake-definition.ts), where `npx workbench-ai intake
|
|
7
|
+
// generate` checks a project's files with it before they are ever sent. A test
|
|
8
|
+
// there keeps the two byte for byte the same: change one, copy it to the other.
|
|
9
|
+
//
|
|
10
|
+
// The schema is a JSON Schema subset, so that every schema can be turned into a
|
|
11
|
+
// structured-output schema (the Gateway's schema.ts) and checked the same way:
|
|
12
|
+
//
|
|
13
|
+
// string enum, maxLength, format (date, date-time, email, phone, uri)
|
|
14
|
+
// integer minimum, maximum
|
|
15
|
+
// number minimum, maximum
|
|
16
|
+
// boolean
|
|
17
|
+
// array of a scalar, maxItems
|
|
18
|
+
// object one level down, of scalars and arrays of scalars
|
|
19
|
+
// array of flat objects (scalars only), maxItems up to 30
|
|
20
|
+
//
|
|
21
|
+
// A property that holds a personal detail is marked with "x-personal"; one whose
|
|
22
|
+
// name or format looks personal and isn't marked is refused (§3, "Personal
|
|
23
|
+
// details are marked").
|
|
24
|
+
export const intakeLimits = {
|
|
25
|
+
types: 20,
|
|
26
|
+
definitionBytes: 16 * 1024,
|
|
27
|
+
allTypesBytes: 64 * 1024,
|
|
28
|
+
properties: 40,
|
|
29
|
+
objectArrayItems: 30,
|
|
30
|
+
examples: 5,
|
|
31
|
+
notThis: 10,
|
|
32
|
+
glossary: 50,
|
|
33
|
+
};
|
|
34
|
+
const PERSONAL_KINDS = ["name", "handle", "phone", "email", "address", "photo"];
|
|
35
|
+
const STRING_FORMATS = ["date", "date-time", "email", "phone", "uri"];
|
|
36
|
+
const DEFAULT_CONFIDENCE_FLOOR = 0.75;
|
|
37
|
+
const NAME = /^[a-z0-9-]{1,48}$/;
|
|
38
|
+
const PROP_NAME = /^[a-z][a-z0-9_]{0,47}$/;
|
|
39
|
+
/** Words in a property name that suggest a personal detail (§3). */
|
|
40
|
+
const PERSONAL_NAME = /(^|_)(name|names|phone|phones|mobile|email|emails|contact|contacts|fb|facebook|profile|avatar|handle|address)(_|$)/;
|
|
41
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
42
|
+
/** A definition that breaks §3, with the type and the JSON path in its message. */
|
|
43
|
+
export class IntakeTypeProblem extends Error {
|
|
44
|
+
}
|
|
45
|
+
function fail(type, path, message) {
|
|
46
|
+
throw new IntakeTypeProblem(`Intake type ${type}: ${path}: ${message}`);
|
|
47
|
+
}
|
|
48
|
+
function onlyKeys(type, path, o, allowed) {
|
|
49
|
+
for (const k of Object.keys(o)) {
|
|
50
|
+
if (!allowed.includes(k))
|
|
51
|
+
fail(type, `${path}.${k}`, `isn't allowed here (allowed: ${allowed.join(", ")}).`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function text(type, path, v, min, max) {
|
|
55
|
+
if (typeof v !== "string" || v.trim().length < min || v.length > max)
|
|
56
|
+
fail(type, path, `must be text of ${min} to ${max} characters.`);
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
function whole(type, path, v, min, max) {
|
|
60
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max)
|
|
61
|
+
fail(type, path, `must be a whole number from ${min} to ${max}.`);
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
function finite(type, path, v) {
|
|
65
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
66
|
+
fail(type, path, "must be a number.");
|
|
67
|
+
return v;
|
|
68
|
+
}
|
|
69
|
+
function personalOf(type, path, p) {
|
|
70
|
+
const v = p["x-personal"];
|
|
71
|
+
if (v === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (typeof v !== "string" || !PERSONAL_KINDS.includes(v))
|
|
74
|
+
fail(type, `${path}.x-personal`, `must be one of ${PERSONAL_KINDS.join(", ")}.`);
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
function scalar(type, path, name, p, count) {
|
|
78
|
+
if (!isObject(p))
|
|
79
|
+
fail(type, path, "must be an object.");
|
|
80
|
+
count.n++;
|
|
81
|
+
const kind = p["type"];
|
|
82
|
+
const common = ["type", "description", "x-personal"];
|
|
83
|
+
const out = { kind: "string" };
|
|
84
|
+
if (kind === "string") {
|
|
85
|
+
onlyKeys(type, path, p, [...common, "enum", "maxLength", "format"]);
|
|
86
|
+
if (p["enum"] !== undefined) {
|
|
87
|
+
const e = p["enum"];
|
|
88
|
+
if (!Array.isArray(e) || e.length < 1 || e.length > 100 || e.some((x) => typeof x !== "string" || x.length < 1 || x.length > 100)) {
|
|
89
|
+
fail(type, `${path}.enum`, "must list 1 to 100 strings of 1 to 100 characters.");
|
|
90
|
+
}
|
|
91
|
+
if (new Set(e).size !== e.length)
|
|
92
|
+
fail(type, `${path}.enum`, "has a value twice.");
|
|
93
|
+
out.enum = e;
|
|
94
|
+
}
|
|
95
|
+
if (p["maxLength"] !== undefined)
|
|
96
|
+
out.maxLength = whole(type, `${path}.maxLength`, p["maxLength"], 1, 20_000);
|
|
97
|
+
if (p["format"] !== undefined) {
|
|
98
|
+
if (!STRING_FORMATS.includes(p["format"]))
|
|
99
|
+
fail(type, `${path}.format`, `must be one of ${STRING_FORMATS.join(", ")}.`);
|
|
100
|
+
out.format = p["format"];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (kind === "integer" || kind === "number") {
|
|
104
|
+
onlyKeys(type, path, p, [...common, "minimum", "maximum"]);
|
|
105
|
+
out.kind = kind;
|
|
106
|
+
if (p["minimum"] !== undefined)
|
|
107
|
+
out.minimum = finite(type, `${path}.minimum`, p["minimum"]);
|
|
108
|
+
if (p["maximum"] !== undefined)
|
|
109
|
+
out.maximum = finite(type, `${path}.maximum`, p["maximum"]);
|
|
110
|
+
if (out.minimum !== undefined && out.maximum !== undefined && out.minimum > out.maximum)
|
|
111
|
+
fail(type, path, "minimum is above maximum.");
|
|
112
|
+
}
|
|
113
|
+
else if (kind === "boolean") {
|
|
114
|
+
onlyKeys(type, path, p, common);
|
|
115
|
+
out.kind = "boolean";
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
fail(type, `${path}.type`, "must be string, integer, number or boolean here.");
|
|
119
|
+
}
|
|
120
|
+
if (p["description"] !== undefined)
|
|
121
|
+
out.description = text(type, `${path}.description`, p["description"], 1, 600);
|
|
122
|
+
const personal = personalOf(type, path, p);
|
|
123
|
+
if (personal)
|
|
124
|
+
out.personal = personal;
|
|
125
|
+
else if (PERSONAL_NAME.test(name) || out.format === "phone" || out.format === "email") {
|
|
126
|
+
fail(type, path, `looks like a personal detail. Mark it with "x-personal" (${PERSONAL_KINDS.join(", ")}) if the type really needs it, or leave it out (docs/message-intake.md §3).`);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
function scalarArray(type, path, name, p, count) {
|
|
131
|
+
onlyKeys(type, path, p, ["type", "items", "maxItems", "description", "x-personal"]);
|
|
132
|
+
count.n++;
|
|
133
|
+
const items = scalar(type, `${path}.items`, name, p["items"], { n: 0 });
|
|
134
|
+
const out = { kind: "array", items };
|
|
135
|
+
if (p["maxItems"] !== undefined)
|
|
136
|
+
out.maxItems = whole(type, `${path}.maxItems`, p["maxItems"], 1, 100);
|
|
137
|
+
if (p["description"] !== undefined)
|
|
138
|
+
out.description = text(type, `${path}.description`, p["description"], 1, 600);
|
|
139
|
+
const personal = personalOf(type, path, p) ?? items.personal;
|
|
140
|
+
if (personal)
|
|
141
|
+
out.personal = personal;
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function requiredOf(type, path, o, props) {
|
|
145
|
+
const r = o["required"];
|
|
146
|
+
if (r === undefined)
|
|
147
|
+
return [];
|
|
148
|
+
if (!Array.isArray(r) || r.some((x) => typeof x !== "string"))
|
|
149
|
+
fail(type, `${path}.required`, "must be a list of property names.");
|
|
150
|
+
for (const x of r)
|
|
151
|
+
if (!Object.hasOwn(props, x))
|
|
152
|
+
fail(type, `${path}.required`, `names ${x}, which isn't a property.`);
|
|
153
|
+
if (new Set(r).size !== r.length)
|
|
154
|
+
fail(type, `${path}.required`, "names a property twice.");
|
|
155
|
+
return r;
|
|
156
|
+
}
|
|
157
|
+
function propsOf(type, path, o) {
|
|
158
|
+
const props = o["properties"];
|
|
159
|
+
if (!isObject(props) || Object.keys(props).length === 0)
|
|
160
|
+
fail(type, `${path}.properties`, "must be an object with at least one property.");
|
|
161
|
+
for (const k of Object.keys(props))
|
|
162
|
+
if (!PROP_NAME.test(k))
|
|
163
|
+
fail(type, `${path}.properties.${k}`, "a property name must be [a-z][a-z0-9_]{0,47}.");
|
|
164
|
+
return props;
|
|
165
|
+
}
|
|
166
|
+
function property(type, path, name, p, count, depth) {
|
|
167
|
+
if (!isObject(p))
|
|
168
|
+
fail(type, path, "must be an object.");
|
|
169
|
+
if (p["type"] === "object") {
|
|
170
|
+
if (depth === 1)
|
|
171
|
+
fail(type, path, "an object can only be one level down.");
|
|
172
|
+
onlyKeys(type, path, p, ["type", "properties", "required", "description", "x-personal"]);
|
|
173
|
+
count.n++;
|
|
174
|
+
const raw = propsOf(type, path, p);
|
|
175
|
+
const properties = {};
|
|
176
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
177
|
+
const child = property(type, `${path}.properties.${k}`, k, v, count, 1);
|
|
178
|
+
if (child.kind === "object" || child.kind === "object_array")
|
|
179
|
+
fail(type, `${path}.properties.${k}`, "an object's properties must be scalars or lists of scalars.");
|
|
180
|
+
properties[k] = child;
|
|
181
|
+
}
|
|
182
|
+
const out = { kind: "object", properties, required: requiredOf(type, path, p, raw) };
|
|
183
|
+
if (p["description"] !== undefined)
|
|
184
|
+
out.description = text(type, `${path}.description`, p["description"], 1, 600);
|
|
185
|
+
const personal = personalOf(type, path, p);
|
|
186
|
+
if (personal)
|
|
187
|
+
out.personal = personal;
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
if (p["type"] === "array") {
|
|
191
|
+
const items = p["items"];
|
|
192
|
+
if (isObject(items) && items["type"] === "object") {
|
|
193
|
+
if (depth === 1)
|
|
194
|
+
fail(type, path, "a list of objects can only be at the top level.");
|
|
195
|
+
onlyKeys(type, path, p, ["type", "items", "maxItems", "description", "x-personal"]);
|
|
196
|
+
onlyKeys(type, `${path}.items`, items, ["type", "properties", "required"]);
|
|
197
|
+
count.n++;
|
|
198
|
+
const raw = propsOf(type, `${path}.items`, items);
|
|
199
|
+
const properties = {};
|
|
200
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
201
|
+
if (isObject(v) && (v["type"] === "object" || v["type"] === "array"))
|
|
202
|
+
fail(type, `${path}.items.properties.${k}`, "a listed object's properties must all be scalars.");
|
|
203
|
+
properties[k] = scalar(type, `${path}.items.properties.${k}`, k, v, count);
|
|
204
|
+
}
|
|
205
|
+
if (p["maxItems"] === undefined)
|
|
206
|
+
fail(type, `${path}.maxItems`, `is required on a list of objects (up to ${intakeLimits.objectArrayItems}).`);
|
|
207
|
+
const out = {
|
|
208
|
+
kind: "object_array",
|
|
209
|
+
items: { properties, required: requiredOf(type, `${path}.items`, items, raw) },
|
|
210
|
+
maxItems: whole(type, `${path}.maxItems`, p["maxItems"], 1, intakeLimits.objectArrayItems),
|
|
211
|
+
};
|
|
212
|
+
if (p["description"] !== undefined)
|
|
213
|
+
out.description = text(type, `${path}.description`, p["description"], 1, 600);
|
|
214
|
+
const personal = personalOf(type, path, p);
|
|
215
|
+
if (personal)
|
|
216
|
+
out.personal = personal;
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
return scalarArray(type, path, name, p, count);
|
|
220
|
+
}
|
|
221
|
+
return scalar(type, path, name, p, count);
|
|
222
|
+
}
|
|
223
|
+
const DEFINITION_KEYS = ["$comment", "format", "name", "version", "description", "not_this", "examples", "glossary", "schema", "confidence_floor", "model", "save_input"];
|
|
224
|
+
/** Checks one definition. Throws IntakeTypeProblem with the type and the JSON path. */
|
|
225
|
+
export function checkDefinition(def, index) {
|
|
226
|
+
if (!isObject(def))
|
|
227
|
+
throw new IntakeTypeProblem(`types[${index}] must be an intake type definition (an object).`);
|
|
228
|
+
const name = typeof def["name"] === "string" && NAME.test(def["name"]) ? def["name"] : null;
|
|
229
|
+
if (!name)
|
|
230
|
+
throw new IntakeTypeProblem(`types[${index}].name must be [a-z0-9-]{1,48}.`);
|
|
231
|
+
if (name === "unknown")
|
|
232
|
+
fail(name, "name", "unknown is reserved.");
|
|
233
|
+
onlyKeys(name, "$", def, DEFINITION_KEYS);
|
|
234
|
+
if (def["format"] !== 1)
|
|
235
|
+
fail(name, "format", "must be 1.");
|
|
236
|
+
const version = whole(name, "version", def["version"], 1, 1_000_000);
|
|
237
|
+
const description = text(name, "description", def["description"], 1, 600);
|
|
238
|
+
const notThis = def["not_this"] ?? [];
|
|
239
|
+
if (!Array.isArray(notThis) || notThis.length > intakeLimits.notThis)
|
|
240
|
+
fail(name, "not_this", `must be a list of up to ${intakeLimits.notThis} lines.`);
|
|
241
|
+
notThis.forEach((l, i) => text(name, `not_this[${i}]`, l, 1, 200));
|
|
242
|
+
const glossary = def["glossary"] ?? {};
|
|
243
|
+
if (!isObject(glossary) || Object.keys(glossary).length > intakeLimits.glossary)
|
|
244
|
+
fail(name, "glossary", `must be up to ${intakeLimits.glossary} "term": "meaning" pairs.`);
|
|
245
|
+
for (const [k, v] of Object.entries(glossary)) {
|
|
246
|
+
text(name, `glossary`, k, 1, 60);
|
|
247
|
+
text(name, `glossary.${k}`, v, 1, 200);
|
|
248
|
+
}
|
|
249
|
+
const schema = def["schema"];
|
|
250
|
+
if (!isObject(schema) || schema["type"] !== "object")
|
|
251
|
+
fail(name, "schema", "must be an object schema (type: object).");
|
|
252
|
+
onlyKeys(name, "schema", schema, ["type", "properties", "required", "description"]);
|
|
253
|
+
const count = { n: 0 };
|
|
254
|
+
const raw = propsOf(name, "schema", schema);
|
|
255
|
+
const properties = {};
|
|
256
|
+
for (const [k, v] of Object.entries(raw))
|
|
257
|
+
properties[k] = property(name, `schema.properties.${k}`, k, v, count, 0);
|
|
258
|
+
if (count.n > intakeLimits.properties)
|
|
259
|
+
fail(name, "schema", `has ${count.n} properties; the limit is ${intakeLimits.properties} in all.`);
|
|
260
|
+
const required = requiredOf(name, "schema", schema, raw);
|
|
261
|
+
let floor = DEFAULT_CONFIDENCE_FLOOR;
|
|
262
|
+
if (def["confidence_floor"] !== undefined) {
|
|
263
|
+
const f = def["confidence_floor"];
|
|
264
|
+
if (typeof f !== "number" || f < 0.5 || f > 0.95)
|
|
265
|
+
fail(name, "confidence_floor", "must be from 0.5 to 0.95.");
|
|
266
|
+
floor = f;
|
|
267
|
+
}
|
|
268
|
+
const model = def["model"] ?? "claude-default";
|
|
269
|
+
if (model !== "claude-default" && model !== "claude-fast")
|
|
270
|
+
fail(name, "model", "must be claude-default or claude-fast.");
|
|
271
|
+
const save = def["save_input"];
|
|
272
|
+
if (save !== undefined && save !== "none" && save !== "project")
|
|
273
|
+
fail(name, "save_input", "must be none or project.");
|
|
274
|
+
const t = {
|
|
275
|
+
name,
|
|
276
|
+
version,
|
|
277
|
+
description,
|
|
278
|
+
not_this: notThis,
|
|
279
|
+
glossary: glossary,
|
|
280
|
+
examples: [],
|
|
281
|
+
properties,
|
|
282
|
+
required,
|
|
283
|
+
confidence_floor: floor,
|
|
284
|
+
model,
|
|
285
|
+
schema,
|
|
286
|
+
};
|
|
287
|
+
const examples = def["examples"];
|
|
288
|
+
if (!Array.isArray(examples) || examples.length < 1 || examples.length > intakeLimits.examples)
|
|
289
|
+
fail(name, "examples", `must be 1 to ${intakeLimits.examples} examples.`);
|
|
290
|
+
examples.forEach((ex, i) => {
|
|
291
|
+
const path = `examples[${i}]`;
|
|
292
|
+
if (!isObject(ex))
|
|
293
|
+
fail(name, path, "must be an object.");
|
|
294
|
+
onlyKeys(name, path, ex, ["received_at", "text", "data"]);
|
|
295
|
+
const exText = text(name, `${path}.text`, ex["text"], 1, 2000);
|
|
296
|
+
if (ex["received_at"] !== undefined && (typeof ex["received_at"] !== "string" || Number.isNaN(Date.parse(ex["received_at"])))) {
|
|
297
|
+
fail(name, `${path}.received_at`, "must be an ISO date and time.");
|
|
298
|
+
}
|
|
299
|
+
if (!isObject(ex["data"]))
|
|
300
|
+
fail(name, `${path}.data`, "must be an object.");
|
|
301
|
+
const errors = validateData(t, ex["data"], { requireTop: false });
|
|
302
|
+
if (errors.length)
|
|
303
|
+
fail(name, `${path}.data${errors[0].path === "" ? "" : `.${errors[0].path}`}`, errors[0].message);
|
|
304
|
+
t.examples.push({ ...(ex["received_at"] ? { received_at: ex["received_at"] } : {}), text: exText, data: ex["data"] });
|
|
305
|
+
});
|
|
306
|
+
return t;
|
|
307
|
+
}
|
|
308
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
309
|
+
function validDate(s) {
|
|
310
|
+
if (!DATE.test(s))
|
|
311
|
+
return false;
|
|
312
|
+
const d = new Date(`${s}T00:00:00Z`);
|
|
313
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
|
|
314
|
+
}
|
|
315
|
+
const FORMAT_CHECK = {
|
|
316
|
+
date: validDate,
|
|
317
|
+
"date-time": (s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(s) && !Number.isNaN(Date.parse(s)),
|
|
318
|
+
email: (s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s),
|
|
319
|
+
phone: (s) => /^\+?[0-9 ()-]{6,24}$/.test(s),
|
|
320
|
+
uri: (s) => {
|
|
321
|
+
try {
|
|
322
|
+
return /^https?:$/.test(new URL(s).protocol);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
function checkScalar(p, v, path, field, errors) {
|
|
330
|
+
const err = (message) => void errors.push({ path, field, message });
|
|
331
|
+
switch (p.kind) {
|
|
332
|
+
case "string":
|
|
333
|
+
if (typeof v !== "string")
|
|
334
|
+
return err("must be text.");
|
|
335
|
+
if (p.enum && !p.enum.includes(v))
|
|
336
|
+
return err(`must be one of ${p.enum.join(", ")}.`);
|
|
337
|
+
if (p.maxLength !== undefined && v.length > p.maxLength)
|
|
338
|
+
return err(`must be at most ${p.maxLength} characters.`);
|
|
339
|
+
if (p.format && !FORMAT_CHECK[p.format](v))
|
|
340
|
+
return err(`must be a valid ${p.format}.`);
|
|
341
|
+
return;
|
|
342
|
+
case "integer":
|
|
343
|
+
case "number":
|
|
344
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
345
|
+
return err("must be a number.");
|
|
346
|
+
if (p.kind === "integer" && !Number.isInteger(v))
|
|
347
|
+
return err("must be a whole number.");
|
|
348
|
+
if (p.minimum !== undefined && v < p.minimum)
|
|
349
|
+
return err(`must be at least ${p.minimum}.`);
|
|
350
|
+
if (p.maximum !== undefined && v > p.maximum)
|
|
351
|
+
return err(`must be at most ${p.maximum}.`);
|
|
352
|
+
return;
|
|
353
|
+
case "boolean":
|
|
354
|
+
if (typeof v !== "boolean")
|
|
355
|
+
err("must be true or false.");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function checkFlat(props, required, v, path, field, errors) {
|
|
359
|
+
if (!isObject(v)) {
|
|
360
|
+
errors.push({ path, field, message: "must be an object." });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
for (const k of Object.keys(v))
|
|
364
|
+
if (!Object.hasOwn(props, k))
|
|
365
|
+
errors.push({ path: `${path}.${k}`, field, message: "isn't in the schema." });
|
|
366
|
+
for (const r of required)
|
|
367
|
+
if (v[r] === undefined)
|
|
368
|
+
errors.push({ path: `${path}.${r}`, field, message: "is required." });
|
|
369
|
+
for (const [k, p] of Object.entries(props)) {
|
|
370
|
+
if (v[k] === undefined)
|
|
371
|
+
continue;
|
|
372
|
+
if (p.kind === "array")
|
|
373
|
+
checkScalarArray(p, v[k], `${path}.${k}`, field, errors);
|
|
374
|
+
else
|
|
375
|
+
checkScalar(p, v[k], `${path}.${k}`, field, errors);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function checkScalarArray(p, v, path, field, errors) {
|
|
379
|
+
if (!Array.isArray(v)) {
|
|
380
|
+
errors.push({ path, field, message: "must be a list." });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (p.maxItems !== undefined && v.length > p.maxItems)
|
|
384
|
+
errors.push({ path, field, message: `must have at most ${p.maxItems} items.` });
|
|
385
|
+
v.forEach((x, i) => checkScalar(p.items, x, `${path}[${i}]`, field, errors));
|
|
386
|
+
}
|
|
387
|
+
/** Checks data against a type's schema: every rule the structured-output schema can't hold (§4). Top-level required fields are not checked here: missing is worked out separately. */
|
|
388
|
+
export function validateData(t, data, opts = { requireTop: true }) {
|
|
389
|
+
const errors = [];
|
|
390
|
+
if (!isObject(data))
|
|
391
|
+
return [{ path: "", field: "", message: "must be an object." }];
|
|
392
|
+
for (const k of Object.keys(data))
|
|
393
|
+
if (!Object.hasOwn(t.properties, k))
|
|
394
|
+
errors.push({ path: k, field: k, message: "isn't in the schema." });
|
|
395
|
+
if (opts.requireTop)
|
|
396
|
+
for (const r of t.required)
|
|
397
|
+
if (data[r] === undefined)
|
|
398
|
+
errors.push({ path: r, field: r, message: "is required." });
|
|
399
|
+
for (const [k, p] of Object.entries(t.properties)) {
|
|
400
|
+
const v = data[k];
|
|
401
|
+
if (v === undefined)
|
|
402
|
+
continue;
|
|
403
|
+
if (p.kind === "object")
|
|
404
|
+
checkFlat(p.properties, p.required, v, k, k, errors);
|
|
405
|
+
else if (p.kind === "object_array") {
|
|
406
|
+
if (!Array.isArray(v))
|
|
407
|
+
errors.push({ path: k, field: k, message: "must be a list." });
|
|
408
|
+
else {
|
|
409
|
+
if (v.length > p.maxItems)
|
|
410
|
+
errors.push({ path: k, field: k, message: `must have at most ${p.maxItems} items.` });
|
|
411
|
+
v.forEach((x, i) => checkFlat(p.items.properties, p.items.required, x, `${k}[${i}]`, k, errors));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else if (p.kind === "array")
|
|
415
|
+
checkScalarArray(p, v, k, k, errors);
|
|
416
|
+
else
|
|
417
|
+
checkScalar(p, v, k, k, errors);
|
|
418
|
+
}
|
|
419
|
+
return errors;
|
|
420
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class IntakeGenerateError extends Error {
|
|
2
|
+
}
|
|
3
|
+
/** Reads and checks the project's intake files and returns the generated file's text. */
|
|
4
|
+
export declare function intakeGenerated(dir: string): {
|
|
5
|
+
text: string;
|
|
6
|
+
types: string[];
|
|
7
|
+
};
|
|
8
|
+
/** Writes src/intake.generated.ts, or with check only compares it. Returns the lines to print. */
|
|
9
|
+
export declare function intakeGenerate(dir: string, check: boolean): string[];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `npx workbench-ai intake generate [--check]` (docs/message-intake.md §3):
|
|
2
|
+
// checks every workbench/intake/<name>.json with the Gateway's own rules
|
|
3
|
+
// (intake-definition.ts, the same file) and writes src/intake.generated.ts:
|
|
4
|
+
// the definitions, the project's save_input setting from workbench/intake.json,
|
|
5
|
+
// and a TypeScript type for each type's data. --check, for the project's
|
|
6
|
+
// Checks, fails when the generated file is missing or out of date.
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { checkDefinition, intakeLimits, IntakeTypeProblem } from "./intake-definition.js";
|
|
10
|
+
const INTAKE_DIR = "workbench/intake";
|
|
11
|
+
const INTAKE_SETTINGS = "workbench/intake.json";
|
|
12
|
+
const INTAKE_GENERATED = "src/intake.generated.ts";
|
|
13
|
+
export class IntakeGenerateError extends Error {
|
|
14
|
+
}
|
|
15
|
+
const read = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
|
|
16
|
+
function parseJson(path, rel) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(read(path));
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new IntakeGenerateError(`${rel} isn't valid JSON: ${e.message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** workbench/intake.json: `{ "format": 1, "save_input": "none" | "project" }`. Missing means none. */
|
|
25
|
+
function readSettings(dir) {
|
|
26
|
+
const path = join(dir, INTAKE_SETTINGS);
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return "none";
|
|
29
|
+
const s = parseJson(path, INTAKE_SETTINGS);
|
|
30
|
+
if (typeof s !== "object" || s === null || Array.isArray(s))
|
|
31
|
+
throw new IntakeGenerateError(`${INTAKE_SETTINGS} must be an object.`);
|
|
32
|
+
const o = s;
|
|
33
|
+
for (const k of Object.keys(o))
|
|
34
|
+
if (!["$comment", "format", "save_input"].includes(k))
|
|
35
|
+
throw new IntakeGenerateError(`${INTAKE_SETTINGS}: ${k} isn't a setting (format, save_input).`);
|
|
36
|
+
if (o["format"] !== 1)
|
|
37
|
+
throw new IntakeGenerateError(`${INTAKE_SETTINGS}: format must be 1.`);
|
|
38
|
+
const save = o["save_input"] ?? "none";
|
|
39
|
+
if (save !== "none" && save !== "project")
|
|
40
|
+
throw new IntakeGenerateError(`${INTAKE_SETTINGS}: save_input must be none or project.`);
|
|
41
|
+
return save;
|
|
42
|
+
}
|
|
43
|
+
const quote = (s) => JSON.stringify(s);
|
|
44
|
+
const doc = (d, indent) => (d ? `${indent}/** ${d.replace(/\*\//g, "*\\/")} */\n` : "");
|
|
45
|
+
const pascal = (name) => name.replace(/(^|-)([a-z0-9])/g, (_, __, c) => c.toUpperCase());
|
|
46
|
+
function scalarType(p) {
|
|
47
|
+
if (p.kind === "string")
|
|
48
|
+
return p.enum ? p.enum.map(quote).join(" | ") : "string";
|
|
49
|
+
if (p.kind === "boolean")
|
|
50
|
+
return "boolean";
|
|
51
|
+
return "number";
|
|
52
|
+
}
|
|
53
|
+
function fields(props, required, indent) {
|
|
54
|
+
return Object.entries(props)
|
|
55
|
+
.map(([k, p]) => `${doc(p.description, indent)}${indent}${k}${required.includes(k) ? "" : "?"}: ${propType(p, indent)};\n`)
|
|
56
|
+
.join("");
|
|
57
|
+
}
|
|
58
|
+
function propType(p, indent) {
|
|
59
|
+
switch (p.kind) {
|
|
60
|
+
case "object":
|
|
61
|
+
return `{\n${fields(p.properties, p.required, `${indent} `)}${indent}}`;
|
|
62
|
+
case "object_array":
|
|
63
|
+
return `Array<{\n${fields(p.items.properties, p.items.required, `${indent} `)}${indent}}>`;
|
|
64
|
+
case "array": {
|
|
65
|
+
const t = scalarType(p.items);
|
|
66
|
+
return p.items.enum ? `Array<${t}>` : `${t}[]`;
|
|
67
|
+
}
|
|
68
|
+
default:
|
|
69
|
+
return scalarType(p);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** One type's data. Every top-level field may be missing: the reader leaves out what a message doesn't give. */
|
|
73
|
+
function dataInterface(t) {
|
|
74
|
+
return `${doc(t.description, "")}export interface ${pascal(t.name)}Data {\n${fields(t.properties, [], " ")}}\n`;
|
|
75
|
+
}
|
|
76
|
+
/** Reads and checks the project's intake files and returns the generated file's text. */
|
|
77
|
+
export function intakeGenerated(dir) {
|
|
78
|
+
const folder = join(dir, INTAKE_DIR);
|
|
79
|
+
const files = existsSync(folder) ? readdirSync(folder).filter((f) => f.endsWith(".json")).sort() : [];
|
|
80
|
+
if (files.length === 0)
|
|
81
|
+
throw new IntakeGenerateError(`No intake types: add one file per type in ${INTAKE_DIR}/ (<name>.json, docs/message-intake.md §3).`);
|
|
82
|
+
if (files.length > intakeLimits.types)
|
|
83
|
+
throw new IntakeGenerateError(`${files.length} intake types; the Gateway takes at most ${intakeLimits.types} in a call.`);
|
|
84
|
+
const saveInput = readSettings(dir);
|
|
85
|
+
const raws = [];
|
|
86
|
+
const checked = [];
|
|
87
|
+
let total = 0;
|
|
88
|
+
files.forEach((f, i) => {
|
|
89
|
+
const rel = `${INTAKE_DIR}/${f}`;
|
|
90
|
+
const raw = parseJson(join(folder, f), rel);
|
|
91
|
+
const bytes = JSON.stringify(raw).length;
|
|
92
|
+
total += bytes;
|
|
93
|
+
if (bytes > intakeLimits.definitionBytes)
|
|
94
|
+
throw new IntakeGenerateError(`${rel} is over ${intakeLimits.definitionBytes / 1024} KB.`);
|
|
95
|
+
if (total > intakeLimits.allTypesBytes)
|
|
96
|
+
throw new IntakeGenerateError(`The intake types are over ${intakeLimits.allTypesBytes / 1024} KB in all.`);
|
|
97
|
+
let t;
|
|
98
|
+
try {
|
|
99
|
+
t = checkDefinition(raw, i);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (e instanceof IntakeTypeProblem)
|
|
103
|
+
throw new IntakeGenerateError(`${rel}: ${e.message}`);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
if (`${t.name}.json` !== f)
|
|
107
|
+
throw new IntakeGenerateError(`${rel} holds the type ${t.name}: name the file ${t.name}.json.`);
|
|
108
|
+
// The comment is for the file's readers, not the reader: it isn't sent.
|
|
109
|
+
const { $comment: _, ...sent } = raw;
|
|
110
|
+
raws.push(sent);
|
|
111
|
+
checked.push(t);
|
|
112
|
+
});
|
|
113
|
+
const text = [
|
|
114
|
+
"// Generated by `npx workbench-ai intake generate` from workbench/intake/ and",
|
|
115
|
+
"// workbench/intake.json. Don't edit it: change those files and run it again.",
|
|
116
|
+
"",
|
|
117
|
+
'import { defineIntakeTypes } from "@mccaskie-labs/workbench-ai";',
|
|
118
|
+
"",
|
|
119
|
+
checked.map(dataInterface).join("\n"),
|
|
120
|
+
"/** Each intake type's data, by type name. */",
|
|
121
|
+
"export interface IntakeData {",
|
|
122
|
+
...checked.map((t) => ` ${quote(t.name)}: ${pascal(t.name)}Data;`),
|
|
123
|
+
"}",
|
|
124
|
+
"",
|
|
125
|
+
"/** Pass as `types` to ai.intake(). */",
|
|
126
|
+
`export const intakeTypes = defineIntakeTypes<IntakeData>({`,
|
|
127
|
+
` saveInput: ${quote(saveInput)},`,
|
|
128
|
+
` definitions: ${JSON.stringify(raws, null, 2).replace(/\n/g, "\n ")},`,
|
|
129
|
+
"});",
|
|
130
|
+
"",
|
|
131
|
+
].join("\n");
|
|
132
|
+
return { text, types: checked.map((t) => t.name) };
|
|
133
|
+
}
|
|
134
|
+
/** Writes src/intake.generated.ts, or with check only compares it. Returns the lines to print. */
|
|
135
|
+
export function intakeGenerate(dir, check) {
|
|
136
|
+
const { text, types } = intakeGenerated(dir);
|
|
137
|
+
const target = join(dir, INTAKE_GENERATED);
|
|
138
|
+
const current = existsSync(target) ? read(target) : null;
|
|
139
|
+
if (check) {
|
|
140
|
+
if (current !== text)
|
|
141
|
+
throw new IntakeGenerateError(`${INTAKE_GENERATED} is out of date with ${INTAKE_DIR}/: run npx workbench-ai intake generate and commit it.`);
|
|
142
|
+
return [`${INTAKE_GENERATED} is up to date (${types.join(", ")}).`];
|
|
143
|
+
}
|
|
144
|
+
if (current === text)
|
|
145
|
+
return [`${INTAKE_GENERATED} is already up to date (${types.join(", ")}).`];
|
|
146
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
147
|
+
writeFileSync(target, text);
|
|
148
|
+
return [`Wrote ${INTAKE_GENERATED}: ${types.join(", ")}.`];
|
|
149
|
+
}
|
package/dist/intake.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type GatewayTarget } from "./http.ts";
|
|
2
|
+
import type { IntakeResult } from "./replies.ts";
|
|
3
|
+
export type IntakeSaveInput = "none" | "project";
|
|
4
|
+
/** One intake type as written in workbench/intake/<name>.json (§3). */
|
|
5
|
+
export interface IntakeDefinition {
|
|
6
|
+
readonly format: 1;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly version: number;
|
|
9
|
+
readonly save_input?: IntakeSaveInput;
|
|
10
|
+
readonly [key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The project's intake types, from src/intake.generated.ts. `M` maps each
|
|
14
|
+
* type's name to its `data`, so a result is typed by the type it found.
|
|
15
|
+
*/
|
|
16
|
+
export interface IntakeTypes<M extends object = Record<string, Record<string, unknown>>> {
|
|
17
|
+
readonly definitions: readonly IntakeDefinition[];
|
|
18
|
+
/** The project's setting, workbench/intake.json. A type's own `save_input` overrides it. */
|
|
19
|
+
readonly saveInput: IntakeSaveInput;
|
|
20
|
+
/** Type-level only: never set. */
|
|
21
|
+
readonly __data?: M;
|
|
22
|
+
}
|
|
23
|
+
/** Used by the generated file. */
|
|
24
|
+
export declare function defineIntakeTypes<M extends object>(types: {
|
|
25
|
+
definitions: readonly IntakeDefinition[];
|
|
26
|
+
saveInput: IntakeSaveInput;
|
|
27
|
+
}): IntakeTypes<M>;
|
|
28
|
+
export type IntakeImageType = "image/png" | "image/jpeg" | "image/webp";
|
|
29
|
+
/** The bucket the project keeps its input in (INTAKE_STORE, an R2 bucket). */
|
|
30
|
+
export interface IntakeStore {
|
|
31
|
+
put(key: string, value: Uint8Array | string, options?: {
|
|
32
|
+
httpMetadata?: {
|
|
33
|
+
contentType?: string;
|
|
34
|
+
};
|
|
35
|
+
customMetadata?: Record<string, string>;
|
|
36
|
+
}): Promise<unknown>;
|
|
37
|
+
}
|
|
38
|
+
export interface IntakeRequest<M extends object> {
|
|
39
|
+
/** `intakeTypes` from src/intake.generated.ts. */
|
|
40
|
+
types: IntakeTypes<M>;
|
|
41
|
+
/** Pasted text, or a caption to go with the image. Up to 20,000 characters. */
|
|
42
|
+
text?: string;
|
|
43
|
+
/** One screenshot: PNG, JPEG or WebP, up to 5 MB. */
|
|
44
|
+
image?: {
|
|
45
|
+
bytes: Uint8Array;
|
|
46
|
+
mimeType: IntakeImageType;
|
|
47
|
+
};
|
|
48
|
+
/** Classify among these types only. Default: all of them. */
|
|
49
|
+
only?: readonly (keyof M & string)[];
|
|
50
|
+
/** When the message was sent or pasted. Relative dates are read against it. Default: now. */
|
|
51
|
+
receivedAt?: Date | string;
|
|
52
|
+
/** IANA name, e.g. Asia/Manila: pass the country pack's (`country.timeZone.id`). Default UTC. */
|
|
53
|
+
timeZone?: string;
|
|
54
|
+
/** X-Workbench-Job, e.g. intake.quote-request. */
|
|
55
|
+
job: string;
|
|
56
|
+
/** `user:<id>` of the app's own user who sent it in, never the poster. */
|
|
57
|
+
subject?: string;
|
|
58
|
+
/** The app's own id for the record (X-Workbench-Ref), e.g. enquiry:4812, so the charge can be found from it. */
|
|
59
|
+
ref?: string;
|
|
60
|
+
/** Needed when the input is kept (save_input: project): the project's INTAKE_STORE bucket. */
|
|
61
|
+
store?: IntakeStore;
|
|
62
|
+
}
|
|
63
|
+
export type IntakeCheckReason = "unknown_type" | "low_confidence" | "missing_required" | "ambiguous_required" | "invalid_output" | "unreadable_image";
|
|
64
|
+
/** Where the input was kept in the project's own bucket. Only when save_input is project. */
|
|
65
|
+
export interface IntakeSaved {
|
|
66
|
+
image?: {
|
|
67
|
+
key: string;
|
|
68
|
+
sha256: string;
|
|
69
|
+
mime: string;
|
|
70
|
+
bytes: number;
|
|
71
|
+
};
|
|
72
|
+
text: {
|
|
73
|
+
key: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Narrows the drafter's IntakeResult by the project's own types: `data` is the
|
|
78
|
+
* found type's data, typed from the generated file (docs/message-intake.md §9:
|
|
79
|
+
* one result type, which ai.reply takes unchanged).
|
|
80
|
+
*/
|
|
81
|
+
export type IntakeTyped<M extends object> = {
|
|
82
|
+
[K in keyof M & string]: {
|
|
83
|
+
type: K;
|
|
84
|
+
type_version: number;
|
|
85
|
+
data: M[K];
|
|
86
|
+
};
|
|
87
|
+
}[keyof M & string] | {
|
|
88
|
+
type: "unknown";
|
|
89
|
+
type_version: null;
|
|
90
|
+
data: null;
|
|
91
|
+
};
|
|
92
|
+
/** What ai.intake returns: the Gateway's answer (§2) as is, typed by the project's types. */
|
|
93
|
+
export type IntakeReading<M extends object> = IntakeResult & IntakeTyped<M> & {
|
|
94
|
+
workbench: {
|
|
95
|
+
request_id: string;
|
|
96
|
+
[key: string]: unknown;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
export interface AiIntake {
|
|
100
|
+
<M extends object>(req: IntakeRequest<M>): Promise<IntakeReading<M>>;
|
|
101
|
+
}
|
|
102
|
+
export declare function createIntake(target: GatewayTarget): AiIntake;
|
package/dist/intake.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ai.intake: read one social media message (a screenshot, pasted text or both)
|
|
2
|
+
// into one of the project's intake types, through the Gateway's
|
|
3
|
+
// POST /v1/intake/read (docs/message-intake.md).
|
|
4
|
+
//
|
|
5
|
+
// The project's types are files in its own repo, workbench/intake/<name>.json;
|
|
6
|
+
// `npx workbench-ai intake generate` checks them and writes
|
|
7
|
+
// src/intake.generated.ts, whose `intakeTypes` is passed here and sent with
|
|
8
|
+
// every call. Workbench keeps only the call's facts. When the project (or the
|
|
9
|
+
// type) keeps its input (`save_input: project`), this writes the image and the
|
|
10
|
+
// text to the project's own bucket after the call and returns where.
|
|
11
|
+
import { WorkbenchAiError } from "./errors.js";
|
|
12
|
+
import { gatewayFetch } from "./http.js";
|
|
13
|
+
/** Used by the generated file. */
|
|
14
|
+
export function defineIntakeTypes(types) {
|
|
15
|
+
return types;
|
|
16
|
+
}
|
|
17
|
+
const JOB = /^[a-z0-9._-]{1,64}$/;
|
|
18
|
+
const SUBJECT = /^[a-z0-9_-]+:[^\s]{1,128}$/;
|
|
19
|
+
const REF = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
20
|
+
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
21
|
+
const MAX_IMAGE = 5 * 1024 * 1024;
|
|
22
|
+
const MAX_TEXT = 20_000;
|
|
23
|
+
const EXT = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
|
|
24
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
25
|
+
function toBase64(bytes) {
|
|
26
|
+
let s = "";
|
|
27
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
28
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
29
|
+
return btoa(s);
|
|
30
|
+
}
|
|
31
|
+
async function sha256Hex(bytes) {
|
|
32
|
+
const d = await crypto.subtle.digest("SHA-256", bytes);
|
|
33
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
}
|
|
35
|
+
/** Whether a reading of this type keeps its input: the type's own setting, else the project's. */
|
|
36
|
+
function keeps(types, type) {
|
|
37
|
+
const own = types.definitions.find((d) => d.name === type)?.save_input;
|
|
38
|
+
return (own ?? types.saveInput) === "project";
|
|
39
|
+
}
|
|
40
|
+
function check(req) {
|
|
41
|
+
const bad = (m) => new WorkbenchAiError("invalid_request", m);
|
|
42
|
+
if (!req.types || !Array.isArray(req.types.definitions) || req.types.definitions.length === 0) {
|
|
43
|
+
throw bad("types must be intakeTypes from src/intake.generated.ts (run npx workbench-ai intake generate)");
|
|
44
|
+
}
|
|
45
|
+
if (!JOB.test(req.job))
|
|
46
|
+
throw bad("job must match [a-z0-9._-]{1,64}");
|
|
47
|
+
if (req.text === undefined && req.image === undefined)
|
|
48
|
+
throw bad("intake needs text, an image, or both");
|
|
49
|
+
if (req.text !== undefined && (typeof req.text !== "string" || req.text.length > MAX_TEXT))
|
|
50
|
+
throw bad(`text must be up to ${MAX_TEXT} characters`);
|
|
51
|
+
if (req.image !== undefined) {
|
|
52
|
+
if (!IMAGE_TYPES.includes(req.image.mimeType))
|
|
53
|
+
throw bad(`image.mimeType must be one of ${IMAGE_TYPES.join(", ")}`);
|
|
54
|
+
if (!(req.image.bytes instanceof Uint8Array) || req.image.bytes.byteLength === 0 || req.image.bytes.byteLength > MAX_IMAGE)
|
|
55
|
+
throw bad("image must be 1 byte to 5 MB");
|
|
56
|
+
}
|
|
57
|
+
const names = req.types.definitions.map((d) => d.name);
|
|
58
|
+
for (const o of req.only ?? [])
|
|
59
|
+
if (!names.includes(o))
|
|
60
|
+
throw bad(`only names ${o}, which isn't one of the types`);
|
|
61
|
+
// Checked before the call, so a missing bucket never costs a paid reading.
|
|
62
|
+
const could = [...(req.only ?? names), "unknown"];
|
|
63
|
+
if (req.store === undefined && could.some((t) => keeps(req.types, t))) {
|
|
64
|
+
throw new WorkbenchAiError("invalid_config", "This project keeps its intake input (save_input: project), so ai.intake needs store: env.INTAKE_STORE");
|
|
65
|
+
}
|
|
66
|
+
const h = {};
|
|
67
|
+
if (req.subject !== undefined) {
|
|
68
|
+
if (!SUBJECT.test(req.subject))
|
|
69
|
+
throw bad("subject must be type:id");
|
|
70
|
+
h["X-Workbench-Subject"] = req.subject;
|
|
71
|
+
}
|
|
72
|
+
if (req.ref !== undefined) {
|
|
73
|
+
if (!REF.test(req.ref))
|
|
74
|
+
throw bad("ref must be [A-Za-z0-9._:-]{1,64}, e.g. enquiry:4812");
|
|
75
|
+
h["X-Workbench-Ref"] = req.ref;
|
|
76
|
+
}
|
|
77
|
+
return h;
|
|
78
|
+
}
|
|
79
|
+
export function createIntake(target) {
|
|
80
|
+
return async function intake(req) {
|
|
81
|
+
const headers = check(req);
|
|
82
|
+
const receivedAt = req.receivedAt instanceof Date ? req.receivedAt.toISOString() : req.receivedAt;
|
|
83
|
+
const { response, requestId } = await gatewayFetch(target, "/v1/intake/read", {
|
|
84
|
+
job: req.job,
|
|
85
|
+
headers,
|
|
86
|
+
body: {
|
|
87
|
+
types: req.types.definitions,
|
|
88
|
+
...(req.only !== undefined ? { only: req.only } : {}),
|
|
89
|
+
...(req.text !== undefined ? { text: req.text } : {}),
|
|
90
|
+
...(req.image !== undefined ? { image: { mime_type: req.image.mimeType, data_base64: toBase64(req.image.bytes) } } : {}),
|
|
91
|
+
...(receivedAt !== undefined ? { received_at: receivedAt } : {}),
|
|
92
|
+
...(req.timeZone !== undefined ? { time_zone: req.timeZone } : {}),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const body = (await response.json().catch(() => null));
|
|
96
|
+
if (!body || typeof body["type"] !== "string")
|
|
97
|
+
throw new WorkbenchAiError("upstream_error", "The Gateway returned no reading", { ...(requestId ? { requestId } : {}) });
|
|
98
|
+
// The Gateway's answer as it comes (the drafter reads these names), with the request id always there.
|
|
99
|
+
const result = { ...body, workbench: { ...(body["workbench"] ?? {}), request_id: str(body["workbench"]?.["request_id"]) || requestId || "" } };
|
|
100
|
+
if (req.store && keeps(req.types, result.type)) {
|
|
101
|
+
try {
|
|
102
|
+
result.saved = await save(req.store, req, result, receivedAt);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
result.save_error = `Couldn't keep the input: ${e.message}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Writes the input to the project's own bucket: images/<sha256>.<ext> and text/<request_id>.json (§5). */
|
|
112
|
+
async function save(store, req, r, receivedAt) {
|
|
113
|
+
const requestId = r.workbench.request_id;
|
|
114
|
+
const meta = { request_id: requestId, type: r.type };
|
|
115
|
+
const saved = { text: { key: `text/${requestId}.json` } };
|
|
116
|
+
if (req.image) {
|
|
117
|
+
const sha256 = await sha256Hex(req.image.bytes);
|
|
118
|
+
const key = `images/${sha256}.${EXT[req.image.mimeType]}`;
|
|
119
|
+
await store.put(key, req.image.bytes, { httpMetadata: { contentType: req.image.mimeType }, customMetadata: meta });
|
|
120
|
+
saved.image = { key, sha256, mime: req.image.mimeType, bytes: req.image.bytes.byteLength };
|
|
121
|
+
}
|
|
122
|
+
const record = {
|
|
123
|
+
request_id: requestId,
|
|
124
|
+
received_at: receivedAt ?? null,
|
|
125
|
+
type: r.type,
|
|
126
|
+
type_version: r.type_version,
|
|
127
|
+
text: req.text ?? null,
|
|
128
|
+
raw_text: r.raw_text ?? "",
|
|
129
|
+
data: r.data,
|
|
130
|
+
...(saved.image ? { image: saved.image.key } : {}),
|
|
131
|
+
};
|
|
132
|
+
await store.put(saved.text.key, JSON.stringify(record), { httpMetadata: { contentType: "application/json" }, customMetadata: meta });
|
|
133
|
+
return saved;
|
|
134
|
+
}
|
package/dist/replies.d.ts
CHANGED
|
@@ -97,6 +97,25 @@ export interface IntakeResult {
|
|
|
97
97
|
receipt?: unknown;
|
|
98
98
|
[key: string]: unknown;
|
|
99
99
|
};
|
|
100
|
+
/** From ai.intake: up to three types with their scores. */
|
|
101
|
+
candidates?: readonly {
|
|
102
|
+
type: string;
|
|
103
|
+
confidence: number;
|
|
104
|
+
}[];
|
|
105
|
+
/** From ai.intake, when the project keeps its input: where it went in the project's own bucket. */
|
|
106
|
+
saved?: {
|
|
107
|
+
image?: {
|
|
108
|
+
key: string;
|
|
109
|
+
sha256: string;
|
|
110
|
+
mime: string;
|
|
111
|
+
bytes: number;
|
|
112
|
+
};
|
|
113
|
+
text: {
|
|
114
|
+
key: string;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** From ai.intake: the input should have been kept and writing it failed. The reading itself is good. */
|
|
118
|
+
save_error?: string;
|
|
100
119
|
}
|
|
101
120
|
export interface ReplyRequest<G extends string = ReplyGuideName> {
|
|
102
121
|
guide: G;
|
package/dist/reply-ui.d.ts
CHANGED
|
@@ -32,5 +32,12 @@ export interface CopyDeps {
|
|
|
32
32
|
export declare function copyReply(text: string, deps: CopyDeps): Promise<"copied" | "selected">;
|
|
33
33
|
/** Runs on the page: wires every box's Copy button. Stringified into REPLY_SCRIPT, so it uses nothing from outside. */
|
|
34
34
|
export declare function mountReplyBoxes(copy: typeof copyReply): void;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The box's script: put it in the page under its nonce, after the boxes.
|
|
37
|
+
*
|
|
38
|
+
* It is the functions' own source, so whatever bundles the app's Worker shapes
|
|
39
|
+
* it. Wrangler's esbuild keeps function names by wrapping them in `__name(…)`,
|
|
40
|
+
* a helper that exists only in the bundle, so the script brings a stand-in of
|
|
41
|
+
* its own: without it the box's buttons do nothing.
|
|
42
|
+
*/
|
|
36
43
|
export declare const REPLY_SCRIPT: string;
|
package/dist/reply-ui.js
CHANGED
|
@@ -142,5 +142,12 @@ export function mountReplyBoxes(copy) {
|
|
|
142
142
|
});
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The box's script: put it in the page under its nonce, after the boxes.
|
|
147
|
+
*
|
|
148
|
+
* It is the functions' own source, so whatever bundles the app's Worker shapes
|
|
149
|
+
* it. Wrangler's esbuild keeps function names by wrapping them in `__name(…)`,
|
|
150
|
+
* a helper that exists only in the bundle, so the script brings a stand-in of
|
|
151
|
+
* its own: without it the box's buttons do nothing.
|
|
152
|
+
*/
|
|
153
|
+
export const REPLY_SCRIPT = `(function(){var __name=function(f){return f};(${mountReplyBoxes.toString()})(${copyReply.toString()});})();`;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { AiIntake } from "./intake.ts";
|
|
2
3
|
import type { WorkbenchEnv } from "@mccaskie-labs/workbench-config";
|
|
3
4
|
import type { DraftWithTemplate, ReplyDraft, ReplyGuide, ReplyGuideName, ReplyRequest } from "./replies.ts";
|
|
4
5
|
import type { OwnVoice, SpellingName, VoiceName } from "./writing.ts";
|
|
@@ -134,6 +135,8 @@ export interface Ai {
|
|
|
134
135
|
budget(): Promise<BudgetStatus>;
|
|
135
136
|
/** Image generation and image search (step 4.8). The key needs the images or search scope. */
|
|
136
137
|
readonly images: AiImages;
|
|
138
|
+
/** Read a social media message into one of the project's intake types (docs/message-intake.md). The key needs the ai scope. */
|
|
139
|
+
readonly intake: AiIntake;
|
|
137
140
|
/**
|
|
138
141
|
* A ready-to-paste reply from facts and a reply guide (docs/capabilities/04-reply-drafter.md).
|
|
139
142
|
* The model places each fact as a token; the package fills and checks them, so a price or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Claude through the Workbench Gateway: text, structured output, streaming, budget status and reply drafts whose facts are checked by code, with no Anthropic key in the app. Refusals and truncations throw; createFakeAi for tests.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@anthropic-ai/sdk": "^1.5.0",
|
|
50
|
-
"@mccaskie-labs/workbench-config": "^0.
|
|
50
|
+
"@mccaskie-labs/workbench-config": "^0.4.0",
|
|
51
51
|
"@mccaskie-labs/workbench-country": "^0.2.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "24.19.2",
|
|
58
|
+
"esbuild": "0.28.2",
|
|
58
59
|
"vitest": "^5.1.0",
|
|
59
60
|
"zod": "4.6.7"
|
|
60
61
|
}
|