@aria-framework/ai 0.1.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/browser/ai-polish.js +199 -0
- package/error.js +78 -0
- package/facts.js +133 -0
- package/generate.js +42 -0
- package/index.js +181 -0
- package/package.json +24 -0
- package/polish.js +156 -0
- package/providers/anthropic.js +192 -0
- package/providers/openai-compatible.js +374 -0
package/polish.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polish — Fix, Tidy, Rephrase and Tone, over any text, as a proposal.
|
|
3
|
+
*
|
|
4
|
+
* IT PROPOSES; IT DOES NOT REPLACE. The caller gets a rewrite and decides — the difference between a
|
|
5
|
+
* tool people use and one they switch off after it eats a paragraph they liked.
|
|
6
|
+
*
|
|
7
|
+
* THE SAFETY PROPERTY IS ENFORCED HERE, not trusted to the prompt: a rewrite that lost a number, a
|
|
8
|
+
* date, a reference, an amount, a link or an address comes back `blocked`, with the values named.
|
|
9
|
+
* The prompt asks; facts.compare checks.
|
|
10
|
+
*
|
|
11
|
+
* FRAMING IS THE APP'S. This engine is generic; the one line that says what is being edited ("a
|
|
12
|
+
* reply to a customer", "a knowledge-base article") is passed in as `framing`. The modes, tones and
|
|
13
|
+
* rules below are audience-neutral defaults an app can use as-is or override.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { AiError } = require('./error');
|
|
19
|
+
const facts = require('./facts');
|
|
20
|
+
|
|
21
|
+
const VERSION = 'polish.v1';
|
|
22
|
+
|
|
23
|
+
/** The most text worth sending in one pass. */
|
|
24
|
+
const MAX_INPUT_CHARS = 8000;
|
|
25
|
+
|
|
26
|
+
/** What the author pressed. Each is a different promise about how much may change. */
|
|
27
|
+
const MODES = {
|
|
28
|
+
fix: {
|
|
29
|
+
label: 'Fix',
|
|
30
|
+
instruction: 'Correct spelling, grammar, punctuation and capitalisation. Change nothing else — ' +
|
|
31
|
+
'not the wording, not the order, not the structure.'
|
|
32
|
+
},
|
|
33
|
+
tidy: {
|
|
34
|
+
label: 'Tidy',
|
|
35
|
+
instruction: 'Improve the structure: break walls of text into paragraphs, turn a sequence of ' +
|
|
36
|
+
'steps into a list, and put any action or next step where it can be seen. Fix spelling and ' +
|
|
37
|
+
'grammar while you are there. Keep the wording that already works.'
|
|
38
|
+
},
|
|
39
|
+
rephrase: {
|
|
40
|
+
label: 'Rephrase',
|
|
41
|
+
instruction: 'Say the same thing more clearly and more briefly. You may reword freely, but the ' +
|
|
42
|
+
'meaning, the commitments and the questions asked must all survive unchanged.'
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Tone is a modifier on any mode, not a mode of its own. Audience-neutral so KB and replies share them. */
|
|
47
|
+
const TONES = {
|
|
48
|
+
warmer: 'Make it warmer and more personable, without adding pleasantries that promise anything.',
|
|
49
|
+
formal: 'Make it more formal. Full sentences, no contractions, no slang.',
|
|
50
|
+
direct: 'Make it more direct. Lead with the point, cut hedging and filler.',
|
|
51
|
+
simpler: 'Make it simpler. Short sentences, plain words, no unexplained jargon.',
|
|
52
|
+
apologetic: 'Acknowledge the inconvenience once, plainly and without grovelling. Do not admit fault that is not already in the text.',
|
|
53
|
+
steps: 'Where the text describes a procedure, rewrite it as clear, numbered steps.'
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const RULES = [
|
|
57
|
+
'NEVER change a number, an amount, a date, a reference, an order number, a link or an email address.',
|
|
58
|
+
'NEVER add information that is not in the text — no facts, no promises, no timeframes, no apologies for things not mentioned.',
|
|
59
|
+
'NEVER remove information. Every point the author made must still be there.',
|
|
60
|
+
'Do not add a greeting or a sign-off that was not already there, and do not remove one that was.',
|
|
61
|
+
'Keep the author\'s own voice. This is their text, edited — not your text.',
|
|
62
|
+
'Return the edited text only. No commentary, no preamble, no explanation inside the text itself.'
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** What must come back. Constrained by the provider, not requested in prose. */
|
|
66
|
+
const SCHEMA = {
|
|
67
|
+
name: 'polish',
|
|
68
|
+
schema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
rewritten: { type: 'string', description: 'The edited text, and nothing else.' },
|
|
72
|
+
changes: {
|
|
73
|
+
type: 'array',
|
|
74
|
+
maxItems: 4,
|
|
75
|
+
description: 'What you changed, one short phrase each, at most four.',
|
|
76
|
+
items: { type: 'string' }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
required: ['rewritten', 'changes'],
|
|
80
|
+
additionalProperties: false
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Build the system prompt from the app's framing plus the chosen mode/tone and optional examples. */
|
|
85
|
+
function system(opts) {
|
|
86
|
+
const modes = opts.modes || MODES;
|
|
87
|
+
const tones = opts.tones || TONES;
|
|
88
|
+
const mode = modes[opts.mode] || MODES.fix;
|
|
89
|
+
const lines = [opts.framing || 'You are editing a piece of text.', '', 'Task: ' + mode.instruction];
|
|
90
|
+
if (opts.tone && tones[opts.tone]) lines.push('Tone: ' + tones[opts.tone]);
|
|
91
|
+
if (opts.houseVoice && opts.houseVoice.length) {
|
|
92
|
+
lines.push('', 'For the house style, here are examples to match:');
|
|
93
|
+
opts.houseVoice.forEach((ex, i) => lines.push(`--- example ${i + 1} ---`, ex));
|
|
94
|
+
lines.push('--- end of examples ---');
|
|
95
|
+
}
|
|
96
|
+
lines.push('', 'Rules:', ...RULES.map((r) => '- ' + r));
|
|
97
|
+
return lines.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalise(s) { return String(s).replace(/\s+/g, ' ').trim(); }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {(opts:object, cfg?:object) => Promise<object>} complete the client's complete()
|
|
104
|
+
* @param {{text:string, mode:string, tone?:string, framing?:string, houseVoice?:string[],
|
|
105
|
+
* modes?:object, tones?:object, maxInputChars?:number, ticketId?:*, signal?:AbortSignal}} opts
|
|
106
|
+
*/
|
|
107
|
+
async function polish(complete, opts) {
|
|
108
|
+
const text = String(opts.text == null ? '' : opts.text);
|
|
109
|
+
const maxChars = opts.maxInputChars || MAX_INPUT_CHARS;
|
|
110
|
+
const modes = opts.modes || MODES;
|
|
111
|
+
const tones = opts.tones || TONES;
|
|
112
|
+
|
|
113
|
+
if (!text.trim()) throw new AiError('refused', 'There is nothing to polish yet.');
|
|
114
|
+
if (text.length > maxChars) {
|
|
115
|
+
throw new AiError('refused',
|
|
116
|
+
`That is ${text.length.toLocaleString()} characters — too long to rewrite in one pass. Polish it a section at a time.`);
|
|
117
|
+
}
|
|
118
|
+
if (!modes[opts.mode]) throw new AiError('refused', `Unknown polish mode: ${opts.mode}`);
|
|
119
|
+
if (opts.tone && !tones[opts.tone]) throw new AiError('refused', `Unknown tone: ${opts.tone}`);
|
|
120
|
+
|
|
121
|
+
const result = await complete({
|
|
122
|
+
system: system({ framing: opts.framing, mode: opts.mode, tone: opts.tone, houseVoice: opts.houseVoice, modes, tones }),
|
|
123
|
+
messages: [{ role: 'user', content: text }],
|
|
124
|
+
// Room for the answer plus the thinking: a reasoning model spends tokens before it writes.
|
|
125
|
+
maxTokens: Math.min(4096, Math.max(1024, Math.ceil(text.length / 2) + 768)),
|
|
126
|
+
temperature: opts.mode === 'fix' ? 0 : 0.3,
|
|
127
|
+
schema: SCHEMA,
|
|
128
|
+
signal: opts.signal,
|
|
129
|
+
ticketId: opts.ticketId || null
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const json = result.json || {};
|
|
133
|
+
const rewritten = String(json.rewritten || '').trim();
|
|
134
|
+
if (!rewritten) throw new AiError('bad_response', 'The model returned no rewritten text.');
|
|
135
|
+
|
|
136
|
+
// THE CONTROL. Everything above this line is persuasion.
|
|
137
|
+
const check = facts.compare(text, rewritten);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
rewritten,
|
|
141
|
+
changes: Array.isArray(json.changes) ? json.changes.slice(0, 4).map(String) : [],
|
|
142
|
+
blocked: !check.ok,
|
|
143
|
+
lost: check.lost,
|
|
144
|
+
blockedReason: check.ok ? null
|
|
145
|
+
: `This rewrite drops ${facts.describe(check.lost)}. Accepting it would change what the text ` +
|
|
146
|
+
'says, so it has to be edited by hand or tried again.',
|
|
147
|
+
unchanged: normalise(rewritten) === normalise(text),
|
|
148
|
+
model: result.model,
|
|
149
|
+
ms: result.ms,
|
|
150
|
+
usage: result.usage,
|
|
151
|
+
truncated: !!result.truncated,
|
|
152
|
+
promptVersion: VERSION
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { polish, MODES, TONES, RULES, SCHEMA, MAX_INPUT_CHARS, VERSION, system };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic's Messages API — the reason this layer has adapters at all.
|
|
3
|
+
*
|
|
4
|
+
* FOUR THINGS DIFFER from the OpenAI shape, and every one of them would break a "just change the
|
|
5
|
+
* base URL" design:
|
|
6
|
+
*
|
|
7
|
+
* 1. the endpoint is /v1/messages, not /chat/completions;
|
|
8
|
+
* 2. auth is `x-api-key`, not `Authorization: Bearer`, and an `anthropic-version` header is
|
|
9
|
+
* REQUIRED — omit it and the request is rejected outright;
|
|
10
|
+
* 3. the system prompt is a top-level `system` parameter, not a message with role 'system';
|
|
11
|
+
* 4. the answer arrives as `content: [{type:'text', text}]` — an array of blocks — rather than
|
|
12
|
+
* `choices[0].message.content`, and usage is `input_tokens`/`output_tokens`.
|
|
13
|
+
*
|
|
14
|
+
* Structured output is `output_config`, which was behind a beta header when this was written. The
|
|
15
|
+
* header is a setting rather than a constant so it can be dropped when the feature goes GA without
|
|
16
|
+
* a code change — and `schemaBeta: ''` turns it off.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { AiError, fromFetchFailure, redact } = require('../error');
|
|
22
|
+
|
|
23
|
+
const API_VERSION = '2023-06-01';
|
|
24
|
+
const DEFAULT_BASE = 'https://api.anthropic.com/v1';
|
|
25
|
+
|
|
26
|
+
async function complete(cfg, opts) {
|
|
27
|
+
const label = cfg.label || 'Claude';
|
|
28
|
+
const url = String(cfg.baseUrl || DEFAULT_BASE).replace(/\/+$/, '') + '/messages';
|
|
29
|
+
|
|
30
|
+
if (!cfg.apiKey) {
|
|
31
|
+
throw new AiError('unconfigured', 'No API key is saved for Claude.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const body = {
|
|
35
|
+
model: cfg.model,
|
|
36
|
+
max_tokens: opts.maxTokens || 1024,
|
|
37
|
+
temperature: opts.temperature == null ? 0.2 : opts.temperature,
|
|
38
|
+
// Difference 3: top-level, not a message.
|
|
39
|
+
...(opts.system ? { system: opts.system } : {}),
|
|
40
|
+
messages: (opts.messages || []).map((m) => ({
|
|
41
|
+
// Anthropic has no 'system' role in the message list; anything that is not the assistant is
|
|
42
|
+
// the user. A caller that passes one gets it folded in rather than silently dropped.
|
|
43
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
44
|
+
content: m.content
|
|
45
|
+
}))
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const headers = {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'x-api-key': cfg.apiKey, // difference 2
|
|
51
|
+
'anthropic-version': API_VERSION
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (opts.schema) {
|
|
55
|
+
body.output_config = {
|
|
56
|
+
format: { type: 'json_schema', schema: opts.schema.schema || opts.schema }
|
|
57
|
+
};
|
|
58
|
+
// Beta at the time of writing. Configurable so GA needs a settings change, not a deploy.
|
|
59
|
+
const beta = cfg.schemaBeta == null ? 'structured-outputs-2025-11-13' : cfg.schemaBeta;
|
|
60
|
+
if (beta) headers['anthropic-beta'] = beta;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 60000);
|
|
66
|
+
if (opts.signal) opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
67
|
+
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
throw fromFetchFailure(err, { label, url });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The timer stays armed until the body is read — see the long note on the same block in
|
|
77
|
+
// openai-compatible.js. Clearing it when headers arrive leaves a stalled body with no deadline,
|
|
78
|
+
// and an await that never settles wedges the runner's single-flight flag permanently.
|
|
79
|
+
let payload;
|
|
80
|
+
try {
|
|
81
|
+
if (!res.ok) throw await httpError(res, label, cfg.apiKey);
|
|
82
|
+
payload = await res.json();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Abort BEFORE instanceof — httpError's swallowed-abort case builds a plausible AiError from a
|
|
85
|
+
// body it never got, and that must not outrank the deadline. Full reasoning on the same block
|
|
86
|
+
// in openai-compatible.js.
|
|
87
|
+
if (controller.signal.aborted || (err && err.name === 'AbortError')) {
|
|
88
|
+
// Stated as a timeout DIRECTLY, not routed through fromFetchFailure — that helper keys on
|
|
89
|
+
// err.name === 'AbortError', and the error in hand here is often httpError's AiError built
|
|
90
|
+
// from a swallowed abort, which it would misfile as 'unreachable'. The signal aborting IS
|
|
91
|
+
// the deadline; no inspection of the error object can outrank that fact.
|
|
92
|
+
throw new AiError('timeout', `${label} did not answer in time.`, { cause: err });
|
|
93
|
+
}
|
|
94
|
+
if (err instanceof AiError) throw err;
|
|
95
|
+
throw new AiError('bad_response', `${label} replied with something that is not JSON.`, { cause: err });
|
|
96
|
+
} finally {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Difference 4: blocks, not a single string. Concatenated so a model that splits its answer over
|
|
101
|
+
// two text blocks does not silently lose the second one.
|
|
102
|
+
// Thinking arrives as its own block TYPE here rather than a field, so filtering to 'text' already
|
|
103
|
+
// excludes it — which is why this adapter needs no tag stripping.
|
|
104
|
+
const text = (payload.content || [])
|
|
105
|
+
.filter((b) => b && b.type === 'text')
|
|
106
|
+
.map((b) => b.text)
|
|
107
|
+
.join('');
|
|
108
|
+
if (!text) {
|
|
109
|
+
const thought = (payload.content || []).some((b) => b && b.type === 'thinking');
|
|
110
|
+
throw new AiError('bad_response', thought
|
|
111
|
+
? `${label} returned only its internal reasoning. Raise the reply limit and try again.`
|
|
112
|
+
: `${label} returned no text (stop reason: ${payload.stop_reason || 'none given'}).`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let json = null;
|
|
116
|
+
if (opts.schema) {
|
|
117
|
+
try {
|
|
118
|
+
json = JSON.parse(text);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Truncation named as truncation — same reasoning as the openai-compatible adapter.
|
|
121
|
+
if (payload.stop_reason === 'max_tokens') {
|
|
122
|
+
throw new AiError('bad_response',
|
|
123
|
+
`${label} ran out of room mid-answer — the structured reply was cut off before it was ` +
|
|
124
|
+
'finished. A Regenerate usually succeeds.', { cause: err });
|
|
125
|
+
}
|
|
126
|
+
throw new AiError('bad_response',
|
|
127
|
+
`${label} was asked for structured output and returned text that will not parse.`, { cause: err });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
text,
|
|
133
|
+
json,
|
|
134
|
+
model: payload.model || cfg.model,
|
|
135
|
+
usage: normaliseUsage(payload.usage),
|
|
136
|
+
ms: Date.now() - started,
|
|
137
|
+
finishReason: payload.stop_reason || null,
|
|
138
|
+
truncated: payload.stop_reason === 'max_tokens',
|
|
139
|
+
reasonedFor: 0
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function httpError(res, label, apiKey) {
|
|
144
|
+
let detail = '';
|
|
145
|
+
try {
|
|
146
|
+
const body = await res.json();
|
|
147
|
+
// Redacted here, not at each use: Anthropic's 400 and 500 bodies quote the offending key.
|
|
148
|
+
detail = redact((body && body.error && body.error.message) || '', apiKey);
|
|
149
|
+
} catch (_) { /* a non-JSON error body is not worth a second failure */ }
|
|
150
|
+
|
|
151
|
+
if (res.status === 401 || res.status === 403) {
|
|
152
|
+
return new AiError('auth', `${label} rejected the API key.`, { status: res.status });
|
|
153
|
+
}
|
|
154
|
+
if (res.status === 429) {
|
|
155
|
+
return new AiError('rate_limit', `${label} is rate limiting this app.`, { status: res.status });
|
|
156
|
+
}
|
|
157
|
+
if (res.status === 400 && /output_config|structured/i.test(detail)) {
|
|
158
|
+
// Worth its own message: it means the beta header moved, not that anything here is broken.
|
|
159
|
+
return new AiError('refused',
|
|
160
|
+
`${label} refused the structured-output request — the beta header may have changed. ${detail}`,
|
|
161
|
+
{ status: res.status });
|
|
162
|
+
}
|
|
163
|
+
return new AiError('bad_response', `${label} returned ${res.status}. ${detail}`.trim(), { status: res.status });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normaliseUsage(u) {
|
|
167
|
+
if (!u) return { prompt: 0, completion: 0, total: 0 };
|
|
168
|
+
const prompt = u.input_tokens || 0; // named differently to the OpenAI shape
|
|
169
|
+
const completion = u.output_tokens || 0;
|
|
170
|
+
return { prompt, completion, total: prompt + completion };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** No listing endpoint is used here — the model name is typed, and a wrong one fails loudly. */
|
|
174
|
+
async function listModels() { return []; }
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Anthropic does not offer an embeddings API. This is not a gap in the adapter.
|
|
178
|
+
*
|
|
179
|
+
* SAID PLAINLY RATHER THAN FAKED. The obvious alternatives were both worse: returning an empty
|
|
180
|
+
* array would make the re-rank silently do nothing and look like a working feature, and quietly
|
|
181
|
+
* calling a third party (Anthropic's own guidance points at Voyage AI) would send customer text to
|
|
182
|
+
* a company the operator never chose. An operator who has picked Anthropic and wants embeddings has
|
|
183
|
+
* to point the embedding side at something that serves them, and this sentence is how they find
|
|
184
|
+
* that out.
|
|
185
|
+
*/
|
|
186
|
+
async function embed() {
|
|
187
|
+
throw new AiError('unsupported',
|
|
188
|
+
'Anthropic does not provide an embeddings API, so related-content re-ranking is unavailable ' +
|
|
189
|
+
'with this provider. Everything else works; results are ordered by text search alone.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { complete, listModels, embed, API_VERSION, DEFAULT_BASE };
|