@mccaskie-labs/workbench-ai 0.1.3 → 0.1.4
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 +8 -0
- package/README.md +3 -0
- package/dist/ai.js +42 -4
- package/dist/capture.d.ts +6 -0
- package/dist/fake.d.ts +3 -0
- package/dist/fake.js +18 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/writing.d.ts +104 -0
- package/dist/writing.js +209 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-ai
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Shared voices (`docs/spec.md` §2.8). `workbench-ai`: `voice` and `spelling` options on `text`, `structured`, `stream` and `countTokens` (a Workbench voice by name, or `{ instructions }` for the app's own), `voice` on a text result, and a writing check with no AI call: `checkWriting`, `writingStops`, `writingNotes`, `writingFixNote`, `paragraphBands`. Paragraph length is a target: quiet up to 1.25 times, a note up to double, a stop only past that. `createFakeAi` records the voice and spelling of each call. `workbench-config`: the `ai` manifest lists the voices, spellings, targets and banned phrases, and three probes. `workbench-ai` now needs `workbench-config` 0.1.14.
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @mccaskie-labs/workbench-config@0.1.12
|
|
10
|
+
|
|
3
11
|
## 0.1.3
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -25,6 +25,9 @@ await save({ text: r.text, model: r.model, requestId: r.requestId })
|
|
|
25
25
|
| `ai.budget()` | `{ status: 'ok' \| 'warn' \| 'reached', leftUsd, monthlyUsd, spentUsd, mode, month }` for this key's project and environment, cached 60 s |
|
|
26
26
|
| `ai.images.generate({ prompt, aspectRatio, job, model?, references? })` | One image: `{ bytes, mimeType, model, requestId, costUsd }`. `model` is `image-default` (the default), `image-pro` or `image-openai`; `references` work on the Google aliases only. Needs the `images` scope |
|
|
27
27
|
| `ai.images.search({ q, job, num?, gl?, hl? })` | Serper image search: a list of `{ title, imageUrl, thumbnailUrl, width, height, source, pageUrl }` that also carries `requestId`, `costUsd` and `credits`. Needs the `search` scope |
|
|
28
|
+
| `voice`, `spelling` on `text`, `structured`, `stream`, `countTokens` | `voice: 'conversational'` (or `casual`, `plain`, `formal`, `strict-formal`) has the Gateway add Workbench's house writing standards and that voice ahead of your system prompt; `spelling: 'en-AU'` (or `en-PH`, `en-GB`, `en-US`) adds one line. The text is the same bytes for every app, so it is cached and shared. `voice: { instructions }` is your own voice: it goes first in your own system prompt and no Workbench writing rule is added. A text result carries `voice: { name, spelling, version }` when one was named |
|
|
29
|
+
| `checkWriting(text, { voice?, paragraphTarget?, exempt? })` | Checks text against the house standards with no AI call. Stops: long dashes and the banned phrases. Paragraph length is a target: no issue up to 1.25 times it, a note up to double, a stop only past double. Sentence length is never an issue. `exempt` is required wording to leave out |
|
|
30
|
+
| `writingStops(issues)`, `writingNotes(issues)`, `writingFixNote(issues)` | The stops, the notes, and the text to add to a prompt when asking once more after a stop |
|
|
28
31
|
| `ai.anthropic` | The official `@anthropic-ai/sdk` client pointed at the Gateway, for anything else |
|
|
29
32
|
| `createFakeAi(script)` | The real client against a scripted fake Gateway, for tests: replies in order, refusals, truncations and Gateway errors, every call recorded in `.calls`. No key, no network |
|
|
30
33
|
| `cachedSystem(stable, ...volatile)`, `cachedBlocks(...texts)` | System blocks with a prompt-cache breakpoint |
|
package/dist/ai.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createCapture, isGatewayMeta, REQUEST_ID_HEADER } from "./capture.js";
|
|
|
5
5
|
import { errorFromGateway, WorkbenchAiError } from "./errors.js";
|
|
6
6
|
import { gatewayFetch } from "./http.js";
|
|
7
7
|
import { createImages } from "./images.js";
|
|
8
|
+
import { SPELLING_NAMES, VOICE_NAMES } from "./writing.js";
|
|
8
9
|
const KEY_PREFIX = {
|
|
9
10
|
development: "wbk_dev_",
|
|
10
11
|
staging: "wbk_stg_",
|
|
@@ -59,6 +60,22 @@ export function createAi(opts) {
|
|
|
59
60
|
throw new WorkbenchAiError("invalid_request", "subject must be type:id");
|
|
60
61
|
h["X-Workbench-Subject"] = req.subject;
|
|
61
62
|
}
|
|
63
|
+
// Checked here too, so a wrong name fails before the network. The Gateway refuses it as well.
|
|
64
|
+
if (typeof req.voice === "string") {
|
|
65
|
+
if (!VOICE_NAMES.includes(req.voice))
|
|
66
|
+
throw new WorkbenchAiError("invalid_request", `voice must be one of ${VOICE_NAMES.join(", ")}, or { instructions } for the app's own`);
|
|
67
|
+
h["X-Workbench-Voice"] = req.voice;
|
|
68
|
+
}
|
|
69
|
+
else if (req.voice !== undefined && !isOwnVoice(req.voice)) {
|
|
70
|
+
throw new WorkbenchAiError("invalid_request", "an own voice needs its instructions");
|
|
71
|
+
}
|
|
72
|
+
if (req.spelling !== undefined) {
|
|
73
|
+
// Any case, as the Gateway takes it.
|
|
74
|
+
const spelling = SPELLING_NAMES.find((n) => n.toLowerCase() === String(req.spelling).toLowerCase());
|
|
75
|
+
if (spelling === undefined)
|
|
76
|
+
throw new WorkbenchAiError("invalid_request", `spelling must be one of ${SPELLING_NAMES.join(", ")}`);
|
|
77
|
+
h["X-Workbench-Spelling"] = spelling;
|
|
78
|
+
}
|
|
62
79
|
return h;
|
|
63
80
|
}
|
|
64
81
|
async function create(body, req) {
|
|
@@ -160,7 +177,7 @@ export function createAi(opts) {
|
|
|
160
177
|
const r = await client(cap.fetch).messages.countTokens({
|
|
161
178
|
model: req.model,
|
|
162
179
|
messages: [{ role: "user", content: req.prompt }],
|
|
163
|
-
...(req
|
|
180
|
+
...withOwnVoice(req),
|
|
164
181
|
}, { headers: headers(req) });
|
|
165
182
|
return { inputTokens: r.input_tokens };
|
|
166
183
|
}
|
|
@@ -187,12 +204,26 @@ function params(req, defaultMax) {
|
|
|
187
204
|
max_tokens: req.maxTokens ?? defaultMax,
|
|
188
205
|
messages: [{ role: "user", content: req.prompt }],
|
|
189
206
|
};
|
|
190
|
-
|
|
191
|
-
body.system = req.system;
|
|
207
|
+
Object.assign(body, withOwnVoice(req));
|
|
192
208
|
if (req.effort !== undefined)
|
|
193
209
|
body.output_config = { effort: req.effort };
|
|
194
210
|
return body;
|
|
195
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The app's system prompt. An own voice goes first in it, as the app's own block: the Gateway adds
|
|
214
|
+
* nothing for it, and its own content rules still come before everything.
|
|
215
|
+
*/
|
|
216
|
+
function withOwnVoice(req) {
|
|
217
|
+
if (!isOwnVoice(req.voice))
|
|
218
|
+
return req.system !== undefined ? { system: req.system } : {};
|
|
219
|
+
const own = { type: "text", text: req.voice.instructions };
|
|
220
|
+
if (req.system === undefined || req.system === "")
|
|
221
|
+
return { system: [own] };
|
|
222
|
+
return { system: [own, ...(typeof req.system === "string" ? [{ type: "text", text: req.system }] : req.system)] };
|
|
223
|
+
}
|
|
224
|
+
function isOwnVoice(v) {
|
|
225
|
+
return typeof v === "object" && v !== null && typeof v.instructions === "string" && v.instructions.trim() !== "";
|
|
226
|
+
}
|
|
196
227
|
function metaOf(message, cap) {
|
|
197
228
|
const meta = message.workbench;
|
|
198
229
|
if (isGatewayMeta(meta))
|
|
@@ -219,7 +250,14 @@ export function toTextResult(message, meta) {
|
|
|
219
250
|
.filter((b) => b.type === "text")
|
|
220
251
|
.map((b) => b.text)
|
|
221
252
|
.join("");
|
|
222
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
text,
|
|
255
|
+
model: meta.model,
|
|
256
|
+
requestId: meta.request_id,
|
|
257
|
+
usage: usageOf(message.usage),
|
|
258
|
+
costUsd: meta.cost_usd,
|
|
259
|
+
...(meta.voice ? { voice: meta.voice } : {}),
|
|
260
|
+
};
|
|
223
261
|
}
|
|
224
262
|
function usageOf(u) {
|
|
225
263
|
return {
|
package/dist/capture.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface GatewayMeta {
|
|
|
8
8
|
refused?: boolean;
|
|
9
9
|
alias?: string | null;
|
|
10
10
|
attempts?: number;
|
|
11
|
+
/** Present when the call named a Workbench voice or spelling (docs/spec.md §2.8). */
|
|
12
|
+
voice?: {
|
|
13
|
+
name: string | null;
|
|
14
|
+
spelling: string | null;
|
|
15
|
+
version: number;
|
|
16
|
+
};
|
|
11
17
|
}
|
|
12
18
|
export interface Capture {
|
|
13
19
|
fetch: typeof fetch;
|
package/dist/fake.d.ts
CHANGED
|
@@ -28,6 +28,9 @@ export interface FakeCall {
|
|
|
28
28
|
job: string | null;
|
|
29
29
|
promptVersion: string | null;
|
|
30
30
|
subject: string | null;
|
|
31
|
+
/** The Workbench voice and spelling named (X-Workbench-Voice, X-Workbench-Spelling). */
|
|
32
|
+
voice: string | null;
|
|
33
|
+
spelling: string | null;
|
|
31
34
|
/** The parsed JSON body (Anthropic Messages request), if any. */
|
|
32
35
|
body: Record<string, unknown> | null;
|
|
33
36
|
stream: boolean;
|
package/dist/fake.js
CHANGED
|
@@ -90,6 +90,8 @@ export function fakeGateway(script = {}) {
|
|
|
90
90
|
job: req.headers.get("x-workbench-job"),
|
|
91
91
|
promptVersion: req.headers.get("x-workbench-prompt-version"),
|
|
92
92
|
subject: req.headers.get("x-workbench-subject"),
|
|
93
|
+
voice: req.headers.get("x-workbench-voice"),
|
|
94
|
+
spelling: req.headers.get("x-workbench-spelling"),
|
|
93
95
|
body,
|
|
94
96
|
stream: body?.["stream"] === true,
|
|
95
97
|
};
|
|
@@ -113,6 +115,19 @@ export function fakeGateway(script = {}) {
|
|
|
113
115
|
return fakeImage(script, body, requestId);
|
|
114
116
|
if (url.pathname === "/v1/images/search")
|
|
115
117
|
return fakeSearch(script, body, requestId);
|
|
118
|
+
if (url.pathname.startsWith("/anthropic/")) {
|
|
119
|
+
// As the Gateway does: an unknown name is refused, and a spelling is read in any case.
|
|
120
|
+
const allowed = capabilities.ai.allowed;
|
|
121
|
+
if (call.voice !== null && !allowed.voices.some((v) => v.name === call.voice)) {
|
|
122
|
+
return anthropicError(400, requestId, "invalid_request", `X-Workbench-Voice must be one of ${allowed.voices.map((v) => v.name).join(", ")}.`);
|
|
123
|
+
}
|
|
124
|
+
if (call.spelling !== null) {
|
|
125
|
+
const named = allowed.spellings.find((x) => x.toLowerCase() === call.spelling.toLowerCase());
|
|
126
|
+
if (named === undefined)
|
|
127
|
+
return anthropicError(400, requestId, "invalid_request", `X-Workbench-Spelling must be one of ${allowed.spellings.join(", ")}.`);
|
|
128
|
+
call.spelling = named;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
116
131
|
if (url.pathname === "/anthropic/v1/messages/count_tokens") {
|
|
117
132
|
return json(200, requestId, { input_tokens: script.inputTokens ?? 12 });
|
|
118
133
|
}
|
|
@@ -164,6 +179,9 @@ export function fakeGateway(script = {}) {
|
|
|
164
179
|
cost_usd: reply.costUsd ?? 0.0001,
|
|
165
180
|
refused: stopReason === "refusal",
|
|
166
181
|
attempts: 1,
|
|
182
|
+
...(call.voice !== null || call.spelling !== null
|
|
183
|
+
? { voice: { name: call.voice, spelling: call.spelling, version: capabilities.ai.allowed.writing.voice_set_version } }
|
|
184
|
+
: {}),
|
|
167
185
|
};
|
|
168
186
|
if (call.stream)
|
|
169
187
|
return sse(requestId, message, meta);
|
package/dist/index.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export { cachedSystem, cachedBlocks, provenance } from "./helpers.ts";
|
|
|
4
4
|
export { WorkbenchAiError, type AiErrorCode, type AiErrorDetails } from "./errors.ts";
|
|
5
5
|
export { REQUEST_ID_HEADER, type GatewayMeta } from "./capture.ts";
|
|
6
6
|
export { ASPECT_RATIOS } from "./images.ts";
|
|
7
|
-
export
|
|
7
|
+
export { checkWriting, paragraphBands, paragraphTarget, paragraphsOf, writingFixNote, writingNotes, writingStops, SPELLING_NAMES, VOICE_NAMES, type CheckWritingOptions, type OwnVoice, type SpellingName, type VoiceName, type WritingIssue, } from "./writing.ts";
|
|
8
|
+
export type { Ai, AiImages, AiOptions, AspectRatio, GeneratedImage, ImageGenerateRequest, ImageSearchRequest, ImageSearchResult, ImageSearchResults, AiStream, BudgetStatus, ContentBlockParam, CountTokensRequest, Effort, MessageStreamEvent, Provenance, StructuredRequest, StructuredResult, SystemBlock, TextRequest, TextResult, Usage, VoiceUsed, ZodLike, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { cachedSystem, cachedBlocks, provenance } from "./helpers.js";
|
|
|
4
4
|
export { WorkbenchAiError } from "./errors.js";
|
|
5
5
|
export { REQUEST_ID_HEADER } from "./capture.js";
|
|
6
6
|
export { ASPECT_RATIOS } from "./images.js";
|
|
7
|
+
export { checkWriting, paragraphBands, paragraphTarget, paragraphsOf, writingFixNote, writingNotes, writingStops, SPELLING_NAMES, VOICE_NAMES, } from "./writing.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { WorkbenchEnv } from "@mccaskie-labs/workbench-config";
|
|
3
|
+
import type { OwnVoice, SpellingName, VoiceName } from "./writing.ts";
|
|
3
4
|
export type SystemBlock = Anthropic.TextBlockParam;
|
|
4
5
|
export type ContentBlockParam = Anthropic.ContentBlockParam;
|
|
5
6
|
export type MessageStreamEvent = Anthropic.MessageStreamEvent;
|
|
@@ -24,6 +25,15 @@ export interface TextRequest {
|
|
|
24
25
|
maxTokens?: number;
|
|
25
26
|
effort?: Effort;
|
|
26
27
|
promptVersion?: string;
|
|
28
|
+
/**
|
|
29
|
+
* How the text reads (docs/spec.md §2.8). A name asks the Gateway to add Workbench's house writing
|
|
30
|
+
* standards and that voice ahead of `system`, the same bytes for every app, so they are cached and
|
|
31
|
+
* shared. `{ instructions }` is the app's own voice: it goes first in the app's own system prompt and
|
|
32
|
+
* no Workbench writing rule is added. Left out, nothing is added.
|
|
33
|
+
*/
|
|
34
|
+
voice?: VoiceName | OwnVoice;
|
|
35
|
+
/** The spelling to write in. The Gateway adds one line for it. Separate from the voice. */
|
|
36
|
+
spelling?: SpellingName;
|
|
27
37
|
/** `type:id` of the thing the call is about. */
|
|
28
38
|
subject?: string;
|
|
29
39
|
}
|
|
@@ -59,6 +69,14 @@ export interface TextResult {
|
|
|
59
69
|
requestId: string;
|
|
60
70
|
usage: Usage;
|
|
61
71
|
costUsd: number;
|
|
72
|
+
/** The Workbench voice and spelling the Gateway added, and the version of that text. Absent when none was named. */
|
|
73
|
+
voice?: VoiceUsed;
|
|
74
|
+
}
|
|
75
|
+
/** What wrote the text: store it with the output, beside the model. */
|
|
76
|
+
export interface VoiceUsed {
|
|
77
|
+
name: VoiceName | null;
|
|
78
|
+
spelling: SpellingName | null;
|
|
79
|
+
version: number;
|
|
62
80
|
}
|
|
63
81
|
export interface StructuredResult<T> {
|
|
64
82
|
data: T;
|
|
@@ -72,6 +90,9 @@ export interface CountTokensRequest {
|
|
|
72
90
|
job: string;
|
|
73
91
|
system?: string | SystemBlock[];
|
|
74
92
|
prompt: string | ContentBlockParam[];
|
|
93
|
+
/** As on a text call, so the count includes what the Gateway adds. */
|
|
94
|
+
voice?: VoiceName | OwnVoice;
|
|
95
|
+
spelling?: SpellingName;
|
|
75
96
|
}
|
|
76
97
|
export interface BudgetStatus {
|
|
77
98
|
/** `ok` below the warning level, `warn` at or above it, `reached` at or above the cap. */
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
declare const allowed: {
|
|
2
|
+
readonly aliases: readonly [{
|
|
3
|
+
readonly alias: "claude-default";
|
|
4
|
+
readonly provider: "anthropic";
|
|
5
|
+
readonly default_model: "claude-sonnet-5-5";
|
|
6
|
+
}, {
|
|
7
|
+
readonly alias: "claude-best";
|
|
8
|
+
readonly provider: "anthropic";
|
|
9
|
+
readonly default_model: "claude-opus-5-5";
|
|
10
|
+
}, {
|
|
11
|
+
readonly alias: "claude-fast";
|
|
12
|
+
readonly provider: "anthropic";
|
|
13
|
+
readonly default_model: "claude-haiku-5-5";
|
|
14
|
+
}];
|
|
15
|
+
readonly explicit_models: readonly ["claude-sonnet-5", "claude-opus-5"];
|
|
16
|
+
readonly server_tools: readonly ["web_search", "web_fetch", "code_execution"];
|
|
17
|
+
readonly required_headers: readonly ["X-Workbench-Job"];
|
|
18
|
+
readonly optional_headers: readonly ["X-Workbench-Voice", "X-Workbench-Spelling"];
|
|
19
|
+
readonly voices: readonly [{
|
|
20
|
+
readonly name: "casual";
|
|
21
|
+
readonly paragraph_target: 50;
|
|
22
|
+
readonly sentence_target: 18;
|
|
23
|
+
}, {
|
|
24
|
+
readonly name: "conversational";
|
|
25
|
+
readonly paragraph_target: 60;
|
|
26
|
+
readonly sentence_target: 22;
|
|
27
|
+
}, {
|
|
28
|
+
readonly name: "plain";
|
|
29
|
+
readonly paragraph_target: 60;
|
|
30
|
+
readonly sentence_target: 20;
|
|
31
|
+
}, {
|
|
32
|
+
readonly name: "formal";
|
|
33
|
+
readonly paragraph_target: 80;
|
|
34
|
+
readonly sentence_target: 25;
|
|
35
|
+
}, {
|
|
36
|
+
readonly name: "strict-formal";
|
|
37
|
+
readonly paragraph_target: 80;
|
|
38
|
+
readonly sentence_target: 28;
|
|
39
|
+
}];
|
|
40
|
+
readonly spellings: readonly ["en-AU", "en-PH", "en-GB", "en-US"];
|
|
41
|
+
readonly writing: {
|
|
42
|
+
readonly voice_set_version: 1;
|
|
43
|
+
readonly house_paragraph_target: 80;
|
|
44
|
+
readonly paragraph_grace: 1.25;
|
|
45
|
+
readonly paragraph_ceiling: 2;
|
|
46
|
+
readonly banned_phrases: readonly ["delve", "it's worth noting", "it is worth noting", "it's important to note", "it is important to note", "in today's fast-paced world", "navigate the complexities", "a testament to", "game-changer", "game-changing", "let's dive in", "dive into", "rest assured", "in conclusion", "at the end of the day", "the world of", "unlock the", "embark on", "tapestry"];
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export type VoiceName = (typeof allowed.voices)[number]["name"];
|
|
50
|
+
export type SpellingName = (typeof allowed.spellings)[number];
|
|
51
|
+
/** The Workbench voices an app can name. */
|
|
52
|
+
export declare const VOICE_NAMES: readonly VoiceName[];
|
|
53
|
+
/** The spellings an app can name. */
|
|
54
|
+
export declare const SPELLING_NAMES: readonly SpellingName[];
|
|
55
|
+
/** An app's own voice: its instructions go in the app's own system prompt, and no Workbench writing rule is added. */
|
|
56
|
+
export interface OwnVoice {
|
|
57
|
+
instructions: string;
|
|
58
|
+
}
|
|
59
|
+
export interface WritingIssue {
|
|
60
|
+
/** `stop`: the text breaks a hard limit, so fix or retry it. `note`: worth showing, never a reason to retry. */
|
|
61
|
+
severity: "stop" | "note";
|
|
62
|
+
rule: "long-dash" | "banned-phrase" | "paragraph-length";
|
|
63
|
+
issue: string;
|
|
64
|
+
/** A short quote from the text, where one helps. */
|
|
65
|
+
location?: string;
|
|
66
|
+
suggestion?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface CheckWritingOptions {
|
|
69
|
+
/** The voice the text was written in. Sets the paragraph target; left out, the house target applies. */
|
|
70
|
+
voice?: VoiceName;
|
|
71
|
+
/** Overrides the paragraph target, in words. */
|
|
72
|
+
paragraphTarget?: number;
|
|
73
|
+
/** Required wording (a disclaimer, a quotation) that must be copied exactly, so it is left out of the check. */
|
|
74
|
+
exempt?: string[];
|
|
75
|
+
}
|
|
76
|
+
/** The word counts for a paragraph target: the target, where a note starts (above `grace`) and where a stop starts (above `ceiling`). */
|
|
77
|
+
export declare function paragraphBands(target: number): {
|
|
78
|
+
target: number;
|
|
79
|
+
grace: number;
|
|
80
|
+
ceiling: number;
|
|
81
|
+
};
|
|
82
|
+
/** The paragraph target for a voice, in words. Never above the house target. */
|
|
83
|
+
export declare function paragraphTarget(voice?: VoiceName): number;
|
|
84
|
+
/** The issues that are stops. */
|
|
85
|
+
export declare const writingStops: (issues: readonly WritingIssue[]) => WritingIssue[];
|
|
86
|
+
/** The notes only. */
|
|
87
|
+
export declare const writingNotes: (issues: readonly WritingIssue[]) => WritingIssue[];
|
|
88
|
+
/**
|
|
89
|
+
* A Markdown text's paragraphs and list items, without headings, tables and code. As in Markdown, a
|
|
90
|
+
* blank line ends a paragraph and a single line break doesn't.
|
|
91
|
+
*/
|
|
92
|
+
export declare function paragraphsOf(text: string): string[];
|
|
93
|
+
/**
|
|
94
|
+
* Checks text written for a reader against the Workbench house standards and
|
|
95
|
+
* a voice's paragraph target. Makes no AI call. An app that brought its own
|
|
96
|
+
* voice has its own rules, so this check isn't for its text.
|
|
97
|
+
*/
|
|
98
|
+
export declare function checkWriting(text: string, opts?: CheckWritingOptions): WritingIssue[];
|
|
99
|
+
/**
|
|
100
|
+
* Text to add to a prompt that is asked once more because its answer broke the house standards.
|
|
101
|
+
* Only the stops are listed: a note is never a reason to ask again. Empty when there is no stop.
|
|
102
|
+
*/
|
|
103
|
+
export declare function writingFixNote(issues: readonly WritingIssue[]): string;
|
|
104
|
+
export {};
|
package/dist/writing.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The writing check, with no AI (docs/spec.md §2.8): does a text keep the
|
|
2
|
+
// Workbench house standards and its voice's targets? Brought over from
|
|
3
|
+
// Imprint's checkWritingStyle. The names and figures come from the manifest
|
|
4
|
+
// (capabilities/ai.json), which the Gateway's own text is tested against, so
|
|
5
|
+
// what the model was told and what is checked here can't drift apart.
|
|
6
|
+
//
|
|
7
|
+
// Two things are hard limits: long dashes and the banned phrases. Paragraph
|
|
8
|
+
// length is a target (Robert, 11 November 2026, as in Imprint): up to target x
|
|
9
|
+
// grace passes with no note, up to target x ceiling passes with a note, and
|
|
10
|
+
// only past the ceiling is it a stop. A long sentence is never an issue.
|
|
11
|
+
import { capabilities } from "@mccaskie-labs/workbench-config";
|
|
12
|
+
const allowed = capabilities.ai.allowed;
|
|
13
|
+
const writing = allowed.writing;
|
|
14
|
+
/** The Workbench voices an app can name. */
|
|
15
|
+
export const VOICE_NAMES = allowed.voices.map((v) => v.name);
|
|
16
|
+
/** The spellings an app can name. */
|
|
17
|
+
export const SPELLING_NAMES = [...allowed.spellings];
|
|
18
|
+
/** The word counts for a paragraph target: the target, where a note starts (above `grace`) and where a stop starts (above `ceiling`). */
|
|
19
|
+
export function paragraphBands(target) {
|
|
20
|
+
return { target, grace: Math.round(target * writing.paragraph_grace), ceiling: Math.round(target * writing.paragraph_ceiling) };
|
|
21
|
+
}
|
|
22
|
+
/** The paragraph target for a voice, in words. Never above the house target. */
|
|
23
|
+
export function paragraphTarget(voice) {
|
|
24
|
+
const v = allowed.voices.find((x) => x.name === voice);
|
|
25
|
+
return Math.min(v?.paragraph_target ?? writing.house_paragraph_target, writing.house_paragraph_target);
|
|
26
|
+
}
|
|
27
|
+
/** The issues that are stops. */
|
|
28
|
+
export const writingStops = (issues) => issues.filter((i) => i.severity === "stop");
|
|
29
|
+
/** The notes only. */
|
|
30
|
+
export const writingNotes = (issues) => issues.filter((i) => i.severity === "note");
|
|
31
|
+
const LONG_DASH_RE = /—|\s–\s|\s-\s/g;
|
|
32
|
+
/** A spaced hyphen between two numbers is arithmetic, not punctuation. */
|
|
33
|
+
const BETWEEN_NUMBERS_RE = /\d\s-\s\d/;
|
|
34
|
+
const MOST = 6;
|
|
35
|
+
function quoteAround(text, index) {
|
|
36
|
+
const start = Math.max(0, index - 40);
|
|
37
|
+
const end = Math.min(text.length, index + 40);
|
|
38
|
+
return (start > 0 ? "…" : "") + text.slice(start, end).replace(/\s+/g, " ").trim() + (end < text.length ? "…" : "");
|
|
39
|
+
}
|
|
40
|
+
function firstWords(text, n = 12) {
|
|
41
|
+
const words = text.replace(/\s+/g, " ").trim().split(" ");
|
|
42
|
+
return words.slice(0, n).join(" ") + (words.length > n ? "…" : "");
|
|
43
|
+
}
|
|
44
|
+
function wordCount(text) {
|
|
45
|
+
const t = text.replace(/[*_`#>[\]()]/g, " ").trim();
|
|
46
|
+
return t ? t.split(/\s+/).length : 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A Markdown text's paragraphs and list items, without headings, tables and code. As in Markdown, a
|
|
50
|
+
* blank line ends a paragraph and a single line break doesn't.
|
|
51
|
+
*/
|
|
52
|
+
export function paragraphsOf(text) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let inCode = false;
|
|
55
|
+
let current = [];
|
|
56
|
+
const flush = () => {
|
|
57
|
+
if (current.length)
|
|
58
|
+
out.push(current.join(" ").trim());
|
|
59
|
+
current = [];
|
|
60
|
+
};
|
|
61
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
62
|
+
const line = raw.trim();
|
|
63
|
+
if (line.startsWith("```")) {
|
|
64
|
+
flush();
|
|
65
|
+
inCode = !inCode;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (inCode)
|
|
69
|
+
continue;
|
|
70
|
+
if (!line || /^#{1,6}\s/.test(line) || line.startsWith("|") || /^(-{3,}|\*{3,})$/.test(line)) {
|
|
71
|
+
flush();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (/^([-*+]|\d+[.)])\s+/.test(line)) {
|
|
75
|
+
flush();
|
|
76
|
+
current.push(line.replace(/^([-*+]|\d+[.)])\s+/, ""));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
current.push(line.replace(/^>\s?/, ""));
|
|
80
|
+
}
|
|
81
|
+
flush();
|
|
82
|
+
return out.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
function paragraphIssues(paragraphs, target) {
|
|
85
|
+
const { grace, ceiling } = paragraphBands(target);
|
|
86
|
+
const counted = paragraphs.map((p) => ({ p, words: wordCount(p) }));
|
|
87
|
+
const stops = counted.filter((x) => x.words > ceiling);
|
|
88
|
+
const notes = counted.filter((x) => x.words > grace && x.words <= ceiling);
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const x of stops.slice(0, MOST)) {
|
|
91
|
+
out.push({
|
|
92
|
+
severity: "stop",
|
|
93
|
+
rule: "paragraph-length",
|
|
94
|
+
issue: `A paragraph has ${x.words} words; the limit is ${ceiling} (aim for ${target}).`,
|
|
95
|
+
location: `"${firstWords(x.p)}"`,
|
|
96
|
+
suggestion: "Cut filler and repetition first. If it is still long, split it into two paragraphs with one idea each.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (stops.length > MOST) {
|
|
100
|
+
out.push({
|
|
101
|
+
severity: "stop",
|
|
102
|
+
rule: "paragraph-length",
|
|
103
|
+
issue: `${stops.length - MOST} more paragraphs are over the ${ceiling}-word limit (aim for ${target}).`,
|
|
104
|
+
suggestion: "Shorten every paragraph over the limit.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const x of notes.slice(0, MOST)) {
|
|
108
|
+
out.push({
|
|
109
|
+
severity: "note",
|
|
110
|
+
rule: "paragraph-length",
|
|
111
|
+
issue: `Long paragraph: ${x.words} words, and the target is ${target}. Consider splitting it.`,
|
|
112
|
+
location: `"${firstWords(x.p)}"`,
|
|
113
|
+
suggestion: "Two paragraphs with one idea each read more easily.",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (notes.length > MOST) {
|
|
117
|
+
out.push({ severity: "note", rule: "paragraph-length", issue: `${notes.length - MOST} more long paragraphs are over the ${target}-word target. Consider splitting them.` });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The prose of a Markdown text, for the dash and phrase checks: fenced code, inline code and table
|
|
123
|
+
* rows are taken out (the standards don't apply to code or data), quote marks and list markers are
|
|
124
|
+
* dropped from the start of each line, and a typographic apostrophe reads as a plain one.
|
|
125
|
+
*/
|
|
126
|
+
function proseOf(text) {
|
|
127
|
+
const out = [];
|
|
128
|
+
let inCode = false;
|
|
129
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
130
|
+
const line = raw.trim();
|
|
131
|
+
if (line.startsWith("```")) {
|
|
132
|
+
inCode = !inCode;
|
|
133
|
+
out.push("");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (inCode || line.startsWith("|")) {
|
|
137
|
+
out.push("");
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
out.push(line
|
|
141
|
+
.replace(/^(>\s?)+/, "")
|
|
142
|
+
.replace(/^(([-*+]|\d+[.)])\s+)+/, "")
|
|
143
|
+
.replace(/`[^`]*`/g, " ")
|
|
144
|
+
.replace(/[’‘]/g, "'"));
|
|
145
|
+
}
|
|
146
|
+
return out.join("\n");
|
|
147
|
+
}
|
|
148
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
149
|
+
/**
|
|
150
|
+
* Checks text written for a reader against the Workbench house standards and
|
|
151
|
+
* a voice's paragraph target. Makes no AI call. An app that brought its own
|
|
152
|
+
* voice has its own rules, so this check isn't for its text.
|
|
153
|
+
*/
|
|
154
|
+
export function checkWriting(text, opts = {}) {
|
|
155
|
+
const body = (opts.exempt ?? []).reduce((acc, e) => (e ? acc.split(e).join(" ") : acc), text);
|
|
156
|
+
const prose = proseOf(body);
|
|
157
|
+
const issues = [];
|
|
158
|
+
const quotes = [];
|
|
159
|
+
let dashes = 0;
|
|
160
|
+
for (const m of prose.matchAll(LONG_DASH_RE)) {
|
|
161
|
+
if (m[0] !== "—") {
|
|
162
|
+
// A dash that starts its line is a list marker, and one between two numbers is a minus sign.
|
|
163
|
+
const dashAt = m.index + m[0].search(/[–-]/);
|
|
164
|
+
const lineStart = prose.lastIndexOf("\n", dashAt - 1) + 1;
|
|
165
|
+
if (prose.slice(lineStart, dashAt).trim() === "")
|
|
166
|
+
continue;
|
|
167
|
+
if (BETWEEN_NUMBERS_RE.test(prose.slice(Math.max(0, m.index - 1), m.index + m[0].length + 1)))
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
dashes++;
|
|
171
|
+
if (quotes.length < 3)
|
|
172
|
+
quotes.push(quoteAround(prose, m.index));
|
|
173
|
+
}
|
|
174
|
+
if (dashes > 0) {
|
|
175
|
+
issues.push({
|
|
176
|
+
severity: "stop",
|
|
177
|
+
rule: "long-dash",
|
|
178
|
+
issue: `The text uses ${dashes} long or spaced dash${dashes === 1 ? "" : "es"} (— or " - ") as punctuation, which the house standards ban.`,
|
|
179
|
+
location: quotes.map((q) => `"${q}"`).join(" / "),
|
|
180
|
+
suggestion: "Rewrite each one with a full stop, comma, colon or brackets. Remove every one.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const target = opts.paragraphTarget !== undefined && opts.paragraphTarget >= 1 ? opts.paragraphTarget : paragraphTarget(opts.voice);
|
|
184
|
+
issues.push(...paragraphIssues(paragraphsOf(body), target));
|
|
185
|
+
for (const phrase of writing.banned_phrases) {
|
|
186
|
+
const m = new RegExp(`(^|[^\\p{L}])${escapeRe(phrase)}(?=$|[^\\p{L}])`, "iu").exec(prose);
|
|
187
|
+
if (m) {
|
|
188
|
+
issues.push({
|
|
189
|
+
severity: "stop",
|
|
190
|
+
rule: "banned-phrase",
|
|
191
|
+
issue: `Uses the banned phrase "${phrase}".`,
|
|
192
|
+
location: `"${quoteAround(prose, m.index + m[1].length)}"`,
|
|
193
|
+
suggestion: "Rewrite the sentence without it, or cut the sentence if it adds nothing.",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return issues;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Text to add to a prompt that is asked once more because its answer broke the house standards.
|
|
201
|
+
* Only the stops are listed: a note is never a reason to ask again. Empty when there is no stop.
|
|
202
|
+
*/
|
|
203
|
+
export function writingFixNote(issues) {
|
|
204
|
+
const stops = writingStops(issues);
|
|
205
|
+
if (stops.length === 0)
|
|
206
|
+
return "";
|
|
207
|
+
const lines = stops.map((i) => `- ${i.issue}${i.location ? ` (at: ${i.location})` : ""}${i.suggestion ? ` Fix: ${i.suggestion}` : ""}`);
|
|
208
|
+
return `\n\nIMPORTANT: a previous answer to this request broke the house writing standards:\n${lines.join("\n")}\nWrite the whole answer again with every one of these fixed. Change nothing else.`;
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Claude through the Workbench Gateway: text, structured output, streaming and budget status, with no Anthropic key in the app. Refusals and truncations throw; createFakeAi for tests.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@anthropic-ai/sdk": "^1.5.0",
|
|
39
|
-
"@mccaskie-labs/workbench-config": "^0.1.
|
|
39
|
+
"@mccaskie-labs/workbench-config": "^0.1.14"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"zod": "^4.1.0"
|