@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aria-framework/ai — browser polish widget.
|
|
3
|
+
*
|
|
4
|
+
* Data-attribute driven so it works on any surface without knowing the app. Mark a group:
|
|
5
|
+
*
|
|
6
|
+
* <div data-ai-polish
|
|
7
|
+
* data-ai-polish-endpoint="/admin/kb/ai/polish"
|
|
8
|
+
* data-ai-polish-target="#kb-body" (CSS selector for the textarea; or a <textarea> inside)
|
|
9
|
+
* data-ai-polish-extra='{"kind":"reply"}'> (optional extra POST fields, JSON)
|
|
10
|
+
* <button data-polish="fix">Fix</button>
|
|
11
|
+
* <button data-polish="rephrase" data-polish-tone="simpler">Simpler</button>
|
|
12
|
+
* <div data-ai-polish-panel></div> (where the proposal renders; or one inside the group)
|
|
13
|
+
* </div>
|
|
14
|
+
*
|
|
15
|
+
* It PROPOSES: pressing a button posts the target's text and renders a word-level diff with
|
|
16
|
+
* Accept / Reject. Accept writes the target and fires input+blur (so any autosave picks it up). A
|
|
17
|
+
* rewrite the server marked `blocked` (the fact-guard tripped) cannot be accepted.
|
|
18
|
+
*
|
|
19
|
+
* Served by the consuming app like @aria-framework/theme serves theme-init.js. CSP-safe: no inline
|
|
20
|
+
* JS, and model output only ever reaches the DOM through textContent.
|
|
21
|
+
*/
|
|
22
|
+
(function () {
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
var tokenMeta = document.querySelector('meta[name="csrf-token"]');
|
|
26
|
+
var TOKEN = tokenMeta ? tokenMeta.getAttribute('content') : '';
|
|
27
|
+
|
|
28
|
+
function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
|
|
29
|
+
|
|
30
|
+
function targetOf(group) {
|
|
31
|
+
var sel = group.getAttribute('data-ai-polish-target');
|
|
32
|
+
return (sel && document.querySelector(sel)) || group.querySelector('textarea') || null;
|
|
33
|
+
}
|
|
34
|
+
function panelOf(group) {
|
|
35
|
+
var sel = group.getAttribute('data-ai-polish-panel');
|
|
36
|
+
return (sel && document.querySelector(sel)) || group.querySelector('[data-ai-polish-panel]') || null;
|
|
37
|
+
}
|
|
38
|
+
function extraOf(group) {
|
|
39
|
+
try { return JSON.parse(group.getAttribute('data-ai-polish-extra') || '{}'); } catch (e) { return {}; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function note(panel, text, bad) {
|
|
43
|
+
if (!panel) return;
|
|
44
|
+
clear(panel);
|
|
45
|
+
var d = document.createElement('div');
|
|
46
|
+
d.className = 'small mt-2 ' + (bad ? 'text-danger' : 'text-muted');
|
|
47
|
+
d.textContent = text;
|
|
48
|
+
panel.appendChild(d);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Word-level LCS diff. Char-level marks half a corrected word; sentence-level marks a paragraph. */
|
|
52
|
+
function diffWords(before, after) {
|
|
53
|
+
var a = String(before).split(/(\s+)/);
|
|
54
|
+
var b = String(after).split(/(\s+)/);
|
|
55
|
+
var n = a.length, m = b.length;
|
|
56
|
+
if (n * m > 1200000) return null; // guard the quadratic table on a huge rewrite
|
|
57
|
+
var lcs = [];
|
|
58
|
+
for (var i = 0; i <= n; i++) lcs.push(new Uint32Array(m + 1));
|
|
59
|
+
for (i = n - 1; i >= 0; i--) {
|
|
60
|
+
for (var j = m - 1; j >= 0; j--) {
|
|
61
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
var out = []; i = 0; j = 0;
|
|
65
|
+
while (i < n && j < m) {
|
|
66
|
+
if (a[i] === b[j]) { out.push({ t: 'same', v: a[i] }); i++; j++; }
|
|
67
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push({ t: 'del', v: a[i] }); i++; }
|
|
68
|
+
else { out.push({ t: 'ins', v: b[j] }); j++; }
|
|
69
|
+
}
|
|
70
|
+
while (i < n) { out.push({ t: 'del', v: a[i] }); i++; }
|
|
71
|
+
while (j < m) { out.push({ t: 'ins', v: b[j] }); j++; }
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderDiff(before, after) {
|
|
76
|
+
var pre = document.createElement('div');
|
|
77
|
+
pre.className = 'border rounded p-2 small';
|
|
78
|
+
pre.style.whiteSpace = 'pre-wrap';
|
|
79
|
+
pre.style.maxHeight = '40vh';
|
|
80
|
+
pre.style.overflow = 'auto';
|
|
81
|
+
var parts = diffWords(before, after);
|
|
82
|
+
if (!parts) { pre.textContent = after; return pre; }
|
|
83
|
+
parts.forEach(function (p) {
|
|
84
|
+
if (p.t === 'same') { pre.appendChild(document.createTextNode(p.v)); return; }
|
|
85
|
+
var el = document.createElement(p.t === 'del' ? 'del' : 'ins');
|
|
86
|
+
el.className = p.t === 'del' ? 'polish-del' : 'polish-ins';
|
|
87
|
+
el.textContent = p.v;
|
|
88
|
+
pre.appendChild(el);
|
|
89
|
+
});
|
|
90
|
+
return pre;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function render(group, panel, data, original) {
|
|
94
|
+
var target = targetOf(group);
|
|
95
|
+
clear(panel);
|
|
96
|
+
var card = document.createElement('div');
|
|
97
|
+
card.className = 'card mt-2 border-primary';
|
|
98
|
+
var body = document.createElement('div');
|
|
99
|
+
body.className = 'card-body py-2 px-3';
|
|
100
|
+
card.appendChild(body);
|
|
101
|
+
|
|
102
|
+
var head = document.createElement('div');
|
|
103
|
+
head.className = 'd-flex flex-wrap align-items-center gap-2 mb-2';
|
|
104
|
+
var title = document.createElement('span');
|
|
105
|
+
title.className = 'small fw-semibold text-primary';
|
|
106
|
+
title.textContent = 'Proposed — ' + data.label;
|
|
107
|
+
head.appendChild(title);
|
|
108
|
+
|
|
109
|
+
var actions = document.createElement('span');
|
|
110
|
+
actions.className = 'ms-auto d-flex gap-2';
|
|
111
|
+
var accept = document.createElement('button');
|
|
112
|
+
accept.type = 'button'; accept.className = 'btn btn-sm btn-primary'; accept.textContent = 'Accept';
|
|
113
|
+
var reject = document.createElement('button');
|
|
114
|
+
reject.type = 'button'; reject.className = 'btn btn-sm btn-outline-secondary'; reject.textContent = 'Reject';
|
|
115
|
+
actions.appendChild(accept); actions.appendChild(reject);
|
|
116
|
+
head.appendChild(actions);
|
|
117
|
+
body.appendChild(head);
|
|
118
|
+
|
|
119
|
+
body.appendChild(renderDiff(original, data.rewritten));
|
|
120
|
+
|
|
121
|
+
// The guard's verdict: a blocked rewrite cannot be accepted, not merely discouraged.
|
|
122
|
+
if (data.blocked) {
|
|
123
|
+
accept.disabled = true;
|
|
124
|
+
accept.classList.remove('btn-primary');
|
|
125
|
+
accept.classList.add('btn-outline-danger');
|
|
126
|
+
var warn = document.createElement('div');
|
|
127
|
+
warn.className = 'small text-danger mt-2';
|
|
128
|
+
warn.textContent = data.blockedReason;
|
|
129
|
+
body.appendChild(warn);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var meta = document.createElement('div');
|
|
133
|
+
meta.className = 'small text-muted mt-2';
|
|
134
|
+
var bits = (data.changes || []).slice(0, 4);
|
|
135
|
+
if (!data.blocked && bits.length) bits.push('facts checked');
|
|
136
|
+
meta.textContent = bits.join(' · ') + (bits.length ? ' · ' : '') +
|
|
137
|
+
data.model + ' · ' + (data.ms / 1000).toFixed(1) + 's · ' + data.tokens + ' tokens';
|
|
138
|
+
body.appendChild(meta);
|
|
139
|
+
|
|
140
|
+
if (data.unchanged) {
|
|
141
|
+
var same = document.createElement('div');
|
|
142
|
+
same.className = 'small text-muted';
|
|
143
|
+
same.textContent = 'The model returned the same text — nothing to change.';
|
|
144
|
+
body.appendChild(same);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
panel.appendChild(card);
|
|
148
|
+
|
|
149
|
+
accept.addEventListener('click', function () {
|
|
150
|
+
if (!target || accept.disabled) return;
|
|
151
|
+
target.value = data.rewritten;
|
|
152
|
+
target.dispatchEvent(new Event('input', { bubbles: true })); // straight into any autosave
|
|
153
|
+
target.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
154
|
+
clear(panel);
|
|
155
|
+
target.focus();
|
|
156
|
+
});
|
|
157
|
+
reject.addEventListener('click', function () { clear(panel); if (target) target.focus(); });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
var busy = new WeakMap();
|
|
161
|
+
|
|
162
|
+
function run(group, btn) {
|
|
163
|
+
var endpoint = group.getAttribute('data-ai-polish-endpoint');
|
|
164
|
+
var target = targetOf(group);
|
|
165
|
+
var panel = panelOf(group);
|
|
166
|
+
if (!endpoint || !target || !panel) return;
|
|
167
|
+
|
|
168
|
+
var mode = btn.getAttribute('data-polish');
|
|
169
|
+
var tone = btn.getAttribute('data-polish-tone') || '';
|
|
170
|
+
var original = target.value;
|
|
171
|
+
if (!original.trim()) return note(panel, 'Write something first — there is nothing to polish yet.', true);
|
|
172
|
+
if (busy.get(group)) return;
|
|
173
|
+
busy.set(group, true);
|
|
174
|
+
|
|
175
|
+
var label = tone ? tone.charAt(0).toUpperCase() + tone.slice(1)
|
|
176
|
+
: mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
177
|
+
note(panel, 'Polishing… a local model can take a few seconds.');
|
|
178
|
+
|
|
179
|
+
var fields = Object.assign({ mode: mode, tone: tone, text: original, _csrf: TOKEN }, extraOf(group));
|
|
180
|
+
fetch(endpoint, {
|
|
181
|
+
method: 'POST', credentials: 'same-origin',
|
|
182
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'x-csrf-token': TOKEN },
|
|
183
|
+
body: new URLSearchParams(fields)
|
|
184
|
+
})
|
|
185
|
+
.then(function (r) { return r.json(); })
|
|
186
|
+
.then(function (data) {
|
|
187
|
+
if (!data.ok) return note(panel, data.error || 'The rewrite failed.', true);
|
|
188
|
+
render(group, panel, Object.assign({ label: label }, data), original);
|
|
189
|
+
})
|
|
190
|
+
.catch(function () { note(panel, 'The rewrite could not be requested.', true); })
|
|
191
|
+
.then(function () { busy.set(group, false); });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
Array.prototype.forEach.call(document.querySelectorAll('[data-ai-polish]'), function (group) {
|
|
195
|
+
Array.prototype.forEach.call(group.querySelectorAll('[data-polish]'), function (btn) {
|
|
196
|
+
btn.addEventListener('click', function () { run(group, btn); });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
})();
|
package/error.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error type for everything the AI layer can fail at.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS: every caller of this layer is a page render or a background job, and neither may
|
|
5
|
+
* die because a model was slow or a laptop had LM Studio closed. A typed error with a sentence
|
|
6
|
+
* somebody can act on is what lets the ticket page say "unavailable" and carry on — the same
|
|
7
|
+
* discipline the email provider already follows when SMTP is not configured.
|
|
8
|
+
*
|
|
9
|
+
* `kind` is for code, `message` is for a person. Nothing here leaks a URL, a key or a stack into
|
|
10
|
+
* the message: this text reaches an admin screen.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
/** @typedef {'disabled'|'unconfigured'|'unreachable'|'timeout'|'auth'|'rate_limit'|'bad_response'|'refused'} AiErrorKind */
|
|
16
|
+
|
|
17
|
+
class AiError extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* @param {AiErrorKind} kind
|
|
20
|
+
* @param {string} message written for the admin screen, not the log
|
|
21
|
+
* @param {{cause?: Error, status?: number}} [opts]
|
|
22
|
+
*/
|
|
23
|
+
constructor(kind, message, opts = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'AiError';
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.status = opts.status || null;
|
|
28
|
+
if (opts.cause) this.cause = opts.cause;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when trying again later might work — the job runner uses this to decide on a retry. */
|
|
32
|
+
get retryable() {
|
|
33
|
+
return this.kind === 'unreachable' || this.kind === 'timeout' || this.kind === 'rate_limit';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turn whatever fetch threw into something with a sentence in it.
|
|
39
|
+
*
|
|
40
|
+
* Node's fetch reports a refused connection as a bare `TypeError: fetch failed` with the real cause
|
|
41
|
+
* nested underneath, which is useless on a screen. The common case here — LM Studio simply not
|
|
42
|
+
* running — deserves to say so.
|
|
43
|
+
*/
|
|
44
|
+
function fromFetchFailure(err, { label, url }) {
|
|
45
|
+
if (err && err.name === 'AbortError') {
|
|
46
|
+
return new AiError('timeout', `${label} did not answer in time.`, { cause: err });
|
|
47
|
+
}
|
|
48
|
+
const code = (err && err.cause && err.cause.code) || (err && err.code) || '';
|
|
49
|
+
if (code === 'ECONNREFUSED' || code === 'ERR_CONNECTION_REFUSED') {
|
|
50
|
+
return new AiError('unreachable',
|
|
51
|
+
`Nothing is listening at ${url}. If this is LM Studio, check the local server is started.`,
|
|
52
|
+
{ cause: err });
|
|
53
|
+
}
|
|
54
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
55
|
+
return new AiError('unreachable', `${url} could not be resolved.`, { cause: err });
|
|
56
|
+
}
|
|
57
|
+
return new AiError('unreachable', `${label} could not be reached: ${code || 'connection failed'}.`,
|
|
58
|
+
{ cause: err });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Strip a secret out of text that is about to become a message.
|
|
63
|
+
*
|
|
64
|
+
* FOUND BY SABOTAGE. Both adapters put the provider's own error text into the message they raise,
|
|
65
|
+
* and a provider is entitled to quote your key back at you — Anthropic's 400 and 500 bodies do
|
|
66
|
+
* exactly that. Those messages are rendered on an admin screen and pasted into support threads, so
|
|
67
|
+
* the key would travel with them.
|
|
68
|
+
*
|
|
69
|
+
* Redacting at the point the message is built covers every path at once, rather than each branch
|
|
70
|
+
* being trusted to remember.
|
|
71
|
+
*/
|
|
72
|
+
function redact(text, secret) {
|
|
73
|
+
const t = String(text == null ? '' : text);
|
|
74
|
+
if (!secret || String(secret).length < 8) return t;
|
|
75
|
+
return t.split(String(secret)).join('***');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { AiError, fromFetchFailure, redact };
|
package/facts.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard that makes rewriting safe. It is arithmetic, not a prompt.
|
|
3
|
+
*
|
|
4
|
+
* A rewrite may change every word and must change no FACT. Asking a model politely to leave figures
|
|
5
|
+
* alone is not a control — it is a hope with good manners. So the two versions are compared for the
|
|
6
|
+
* things that carry meaning on a support ticket, and anything that went missing blocks the accept
|
|
7
|
+
* and gets named.
|
|
8
|
+
*
|
|
9
|
+
* WHAT COUNTS AS A FACT here is deliberately narrow: values a customer could act on or dispute — an
|
|
10
|
+
* amount, a date, an order number, a link, an address. Prose is what the agent asked to have
|
|
11
|
+
* changed; these are not.
|
|
12
|
+
*
|
|
13
|
+
* ONE-WAY. Only losses matter. A rewrite that ADDS "Tuesday" where the original said "the 18th" is
|
|
14
|
+
* a judgement call for the reader; a rewrite that drops the 18th is a defect. Flagging additions
|
|
15
|
+
* would block almost every legitimate tidy.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Each kind is a name and a pattern. The name is what the agent is shown, so it is written for
|
|
22
|
+
* them: "amount", not "currency-like numeric token".
|
|
23
|
+
*/
|
|
24
|
+
const KINDS = [
|
|
25
|
+
// Ticket references, first — 89A0-3EYW-A4H5 and the shorter forms staff type in notes. Before
|
|
26
|
+
// the number rule, so a reference is reported as a reference rather than as three numbers.
|
|
27
|
+
{ name: 'ticket reference', re: /\b[0-9A-Z]{4}(?:-[0-9A-Z]{4}){1,3}\b/g },
|
|
28
|
+
{ name: 'email address', re: /\b[^\s@]+@[^\s@.]+\.[^\s@]+\b/g },
|
|
29
|
+
{ name: 'link', re: /\bhttps?:\/\/[^\s<>"')]+/gi },
|
|
30
|
+
// Money with its symbol attached, so R1200 and 1200 are not treated as the same fact.
|
|
31
|
+
//
|
|
32
|
+
// BEFORE `reference`, and that order is the whole point rather than a tidy-up. `R1200` is five
|
|
33
|
+
// [A-Z0-9] characters containing both a letter and a digit, so the reference rule matched it
|
|
34
|
+
// first and blanked it — while `R1 200`, the same amount written with a space, matched the
|
|
35
|
+
// amount rule instead. The same money was classified two different ways depending only on
|
|
36
|
+
// spacing, and compare() therefore reported it as a LOST AMOUNT:
|
|
37
|
+
//
|
|
38
|
+
// compare('Refund R1 200 to order REQ18590', 'We will refund R1200 for REQ18590')
|
|
39
|
+
// -> { ok: false, lost: [{ kind: 'amount', value: 'R1200' }] }
|
|
40
|
+
//
|
|
41
|
+
// polish() then set blocked = true and the Accept button was disabled — on a rewrite that had
|
|
42
|
+
// kept every fact. Normalising `R1 200` to `R1200` is exactly what Tidy and Rephrase do, so the
|
|
43
|
+
// guard was most reliably blocking its own most common legitimate output.
|
|
44
|
+
//
|
|
45
|
+
// THE LOOKBEHIND IS THE FIX, NOT THE RULE ORDER. Reordering amount ahead of reference (the
|
|
46
|
+
// previous attempt) traded one wrong classification for a worse one: with /i and no boundary,
|
|
47
|
+
// the currency letter matched MID-TOKEN, so `SR12345` was consumed as the amount `R12345` and
|
|
48
|
+
// `TAGEUR2024` as `EUR2024` — and because extract() blanks its matches, compare() then WAIVED a
|
|
49
|
+
// rewrite that corrupted `SR12345` into `R12345`, which is the guard approving the exact damage
|
|
50
|
+
// it exists to block. `(?<![A-Za-z0-9])` requires the currency token to start a token.
|
|
51
|
+
//
|
|
52
|
+
// NOT `\b`, deliberately: `$`, `£` and `€` are non-word characters, so after a space there is no
|
|
53
|
+
// word boundary before them and `\b` would silently kill all three symbol currencies.
|
|
54
|
+
{ name: 'amount', re: /(?<![A-Za-z0-9])(?:R|ZAR|\$|£|€|USD|EUR|GBP)\s?\d[\d\s.,]*\d|(?<![A-Za-z0-9])\d[\d\s.,]*\d\s?(?:ZAR|USD|EUR|GBP)(?![A-Za-z0-9])/gi },
|
|
55
|
+
// Any OTHER identifier: uppercase, mixing letters with digits. That is what REQ18590, 7F2C and
|
|
56
|
+
// A4H5 have in common and what ordinary words do not — so the references people actually type in
|
|
57
|
+
// notes are guarded without every capitalised word being flagged. Sits after the email, link and
|
|
58
|
+
// amount rules so it cannot bite a chunk out of any of them. This comment used to claim it sat
|
|
59
|
+
// after the link rule while the code had it before — the order now matches the description.
|
|
60
|
+
{ name: 'reference', re: /\b(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{3,}\b/g },
|
|
61
|
+
{ name: 'date', re: /\b\d{1,4}[/-]\d{1,2}[/-]\d{1,4}\b/g },
|
|
62
|
+
// Everything else numeric. Runs of digits, so "18 Aug" keeps its 18 and a version like 3.5 stays
|
|
63
|
+
// one token rather than becoming two.
|
|
64
|
+
{ name: 'number', re: /\b\d+(?:\.\d+)?\b/g }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pull the facts out of a piece of text.
|
|
69
|
+
*
|
|
70
|
+
* Matched kinds are BLANKED OUT as they are found, so a later pattern cannot re-match part of an
|
|
71
|
+
* earlier one — without that, the ticket reference 89A0-3EYW-A4H5 would also be reported as the
|
|
72
|
+
* numbers 89, 0, 3 and 4.
|
|
73
|
+
*/
|
|
74
|
+
function extract(text) {
|
|
75
|
+
let rest = String(text == null ? '' : text);
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const kind of KINDS) {
|
|
78
|
+
rest = rest.replace(new RegExp(kind.re.source, kind.re.flags), (match) => {
|
|
79
|
+
found.push({ kind: kind.name, value: normalise(kind.name, match) });
|
|
80
|
+
return ' '.repeat(match.length);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Comparable form. Case and internal spacing are not facts — `R1 200` and `r1200` are the same
|
|
88
|
+
* amount, and blocking on that would train people to ignore the warning.
|
|
89
|
+
*/
|
|
90
|
+
function normalise(kind, value) {
|
|
91
|
+
const v = String(value).trim();
|
|
92
|
+
if (kind === 'email address' || kind === 'link') return v.toLowerCase().replace(/[.,;:)]+$/, '');
|
|
93
|
+
if (kind === 'amount') return v.toUpperCase().replace(/\s+/g, '').replace(/,/g, '');
|
|
94
|
+
return v.toUpperCase();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What the rewrite lost.
|
|
99
|
+
*
|
|
100
|
+
* MULTISET, not a set: a note that says "two 24-port switches" and comes back saying "two 24-port
|
|
101
|
+
* switches" once has lost something, and comparing unique values alone would miss it.
|
|
102
|
+
*
|
|
103
|
+
* @returns {{ok: boolean, lost: Array<{kind: string, value: string}>}}
|
|
104
|
+
*/
|
|
105
|
+
/** A separator that cannot occur inside a value. Written as an escape: the literal byte is
|
|
106
|
+
* invisible in an editor and makes every text tool treat this file as binary. */
|
|
107
|
+
const SEP = '\u0000';
|
|
108
|
+
|
|
109
|
+
function compare(before, after) {
|
|
110
|
+
const remaining = new Map();
|
|
111
|
+
for (const f of extract(after)) {
|
|
112
|
+
const key = f.kind + SEP + f.value;
|
|
113
|
+
remaining.set(key, (remaining.get(key) || 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lost = [];
|
|
117
|
+
for (const f of extract(before)) {
|
|
118
|
+
const key = f.kind + SEP + f.value;
|
|
119
|
+
const left = remaining.get(key) || 0;
|
|
120
|
+
if (left > 0) remaining.set(key, left - 1);
|
|
121
|
+
else lost.push(f);
|
|
122
|
+
}
|
|
123
|
+
return { ok: lost.length === 0, lost };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** "the number 24 and the ticket reference 7F2C" — for the sentence an agent reads. */
|
|
127
|
+
function describe(lost) {
|
|
128
|
+
const parts = lost.map((f) => `the ${f.kind} ${f.value}`);
|
|
129
|
+
if (parts.length <= 1) return parts[0] || '';
|
|
130
|
+
return parts.slice(0, -1).join(', ') + ' and ' + parts[parts.length - 1];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { extract, compare, describe, KINDS };
|
package/generate.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate — one schema-constrained JSON generation, as a thin, prompt-agnostic wrapper over
|
|
3
|
+
* complete(). This is the seam a "draft from a topic" feature sits on: the app supplies the system
|
|
4
|
+
* and user prompts and the JSON schema (all content); this handles the call, the budget and the
|
|
5
|
+
* token sizing.
|
|
6
|
+
*
|
|
7
|
+
* UNLIKE polish, there is no fact-guard here — there is no source text to preserve. A generator
|
|
8
|
+
* INVENTS, so the discipline against fabrication has to live in the caller's prompt (e.g. "leave a
|
|
9
|
+
* [specify …] placeholder rather than guess a version number").
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { AiError } = require('./error');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {(opts:object, cfg?:object) => Promise<object>} complete the client's complete()
|
|
18
|
+
* @param {{system?:string, user:string, schema:object, maxTokens?:number, temperature?:number,
|
|
19
|
+
* ticketId?:*, signal?:AbortSignal}} opts
|
|
20
|
+
* @returns {Promise<{json:object|null, model, ms, usage, truncated:boolean}>}
|
|
21
|
+
*/
|
|
22
|
+
async function generate(complete, opts) {
|
|
23
|
+
if (!opts || !opts.schema) throw new AiError('refused', 'generate() requires a schema.');
|
|
24
|
+
const result = await complete({
|
|
25
|
+
system: opts.system || '',
|
|
26
|
+
messages: [{ role: 'user', content: String(opts.user == null ? '' : opts.user) }],
|
|
27
|
+
maxTokens: opts.maxTokens || 1500,
|
|
28
|
+
temperature: opts.temperature == null ? 0.3 : opts.temperature,
|
|
29
|
+
schema: opts.schema,
|
|
30
|
+
signal: opts.signal,
|
|
31
|
+
ticketId: opts.ticketId || null
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
json: result.json || null,
|
|
35
|
+
model: result.model,
|
|
36
|
+
ms: result.ms,
|
|
37
|
+
usage: result.usage,
|
|
38
|
+
truncated: !!result.truncated
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { generate };
|
package/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aria-framework/ai — the AI seam. One `complete()`, several providers behind it, plus the
|
|
3
|
+
* writing-assist engines (polish/generate) and the fact-preservation guard.
|
|
4
|
+
*
|
|
5
|
+
* DEPENDENCY-INJECTED, DATABASE-FREE. The package knows how to talk to a model; it does NOT know
|
|
6
|
+
* where an app keeps its settings, its credentials or its token ledger. The consumer builds a
|
|
7
|
+
* client with two functions of its own:
|
|
8
|
+
*
|
|
9
|
+
* const ai = createAiClient({
|
|
10
|
+
* resolveConfig, // async () => resolved config (provider, baseUrl, model, apiKey, caps…)
|
|
11
|
+
* budget, // { assertWithinBudget(cfg, ctx), record(cfg, result, ctx) } — optional
|
|
12
|
+
* logger // { info, warn, error } — optional
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* The provider adapters already take an explicit config and never read a database, which is what
|
|
16
|
+
* makes the seam testable: a stub adapter and a real adapter are called identically.
|
|
17
|
+
*
|
|
18
|
+
* PROMPTS ARE CONTENT AND LIVE IN THE APP. This package carries the mechanism (how to call a model,
|
|
19
|
+
* how to enforce a token ceiling, how to check a rewrite kept its facts) and generic writing
|
|
20
|
+
* operations; the words that say "you are editing a reply to a customer" belong to the app.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const facts = require('./facts');
|
|
26
|
+
const { AiError, fromFetchFailure, redact } = require('./error');
|
|
27
|
+
const { polish } = require('./polish');
|
|
28
|
+
const { generate } = require('./generate');
|
|
29
|
+
|
|
30
|
+
const PROVIDERS = {
|
|
31
|
+
// 'lmstudio' and 'openai-compatible' are the SAME adapter with different defaults — a kindness to
|
|
32
|
+
// whoever configures it: an operator running LM Studio should not have to know it speaks a shape
|
|
33
|
+
// named after somebody else.
|
|
34
|
+
lmstudio: require('./providers/openai-compatible'),
|
|
35
|
+
'openai-compatible': require('./providers/openai-compatible'),
|
|
36
|
+
anthropic: require('./providers/anthropic')
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const DEFAULTS = {
|
|
40
|
+
lmstudio: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3.5-9b', label: 'LM Studio' },
|
|
41
|
+
'openai-compatible': { baseUrl: 'http://localhost:11434/v1', model: '', label: 'The model server' },
|
|
42
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' }
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const RETRY_AFTER_MS = 400;
|
|
46
|
+
const RETRY_ONLY_IF_FAILED_WITHIN_MS = 5000;
|
|
47
|
+
|
|
48
|
+
const NOOP_LOGGER = { info() {}, warn() {}, error() {} };
|
|
49
|
+
const NOOP_BUDGET = { async assertWithinBudget() {}, async record() {} };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build an AI client bound to one app's config resolution and token budget.
|
|
53
|
+
* @param {{resolveConfig: () => Promise<object>, budget?: object, logger?: object}} deps
|
|
54
|
+
*/
|
|
55
|
+
function createAiClient(deps = {}) {
|
|
56
|
+
const resolveConfig = deps.resolveConfig;
|
|
57
|
+
if (typeof resolveConfig !== 'function') {
|
|
58
|
+
throw new Error('createAiClient: resolveConfig must be an async function returning the resolved config');
|
|
59
|
+
}
|
|
60
|
+
const log = deps.logger || NOOP_LOGGER;
|
|
61
|
+
const meter = deps.budget || NOOP_BUDGET;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Try once more, but only for the failures where trying again could help — a local provider that
|
|
65
|
+
* dropped the connection while loading a model, and nothing else. A cancelled call, a timeout, a
|
|
66
|
+
* rate limit or a slow failure is never retried (see the guards below).
|
|
67
|
+
*/
|
|
68
|
+
async function withOneRetry(run, opts = {}) {
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
try {
|
|
71
|
+
return await run();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (opts.signal && opts.signal.aborted) throw err;
|
|
74
|
+
if (!err || !err.retryable) throw err;
|
|
75
|
+
const elapsed = Date.now() - startedAt;
|
|
76
|
+
// A TIMEOUT is the deadline itself being reached — retrying waits the whole deadline again. A
|
|
77
|
+
// RATE LIMIT is the provider asking for less pressure. A slow `unreachable` is not the
|
|
78
|
+
// sub-second dropped-connection transient this retry exists for. None of those retry.
|
|
79
|
+
if (err.kind === 'timeout' || err.kind === 'rate_limit' || elapsed > RETRY_ONLY_IF_FAILED_WITHIN_MS) {
|
|
80
|
+
log.warn(`AI: ${err.kind} after ${elapsed}ms — not retrying (${err.message})`);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
log.warn(`AI: ${err.kind} — trying once more in ${RETRY_AFTER_MS}ms (${err.message})`);
|
|
84
|
+
await new Promise((r) => setTimeout(r, RETRY_AFTER_MS));
|
|
85
|
+
return run();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ask the configured model for something.
|
|
91
|
+
* @param {{system?:string, messages:Array, maxTokens?:number, temperature?:number,
|
|
92
|
+
* schema?:object, signal?:AbortSignal, ticketId?:*, skipBudget?:boolean}} opts
|
|
93
|
+
* @param {object} [cfgOverride] the resolved config, when the caller already has it
|
|
94
|
+
*/
|
|
95
|
+
async function complete(opts, cfgOverride) {
|
|
96
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
97
|
+
if (!cfg.enabled) {
|
|
98
|
+
throw new AiError('disabled', 'AI assistance is switched off. An administrator can enable it in Settings.');
|
|
99
|
+
}
|
|
100
|
+
if (!cfg.model) {
|
|
101
|
+
throw new AiError('unconfigured', 'No model name is configured.');
|
|
102
|
+
}
|
|
103
|
+
const adapter = PROVIDERS[cfg.provider];
|
|
104
|
+
if (!adapter) {
|
|
105
|
+
throw new AiError('unconfigured', `No adapter is registered for provider "${cfg.provider}".`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The ceiling, before the call — the only place a limit can be enforced without being bypassable
|
|
109
|
+
// by whichever caller forgets. `skipBudget` is for the admin's Test connection; it still records.
|
|
110
|
+
if (!opts.skipBudget) await meter.assertWithinBudget(cfg, { ticketId: opts.ticketId });
|
|
111
|
+
|
|
112
|
+
const result = await withOneRetry(() => adapter.complete(cfg, opts), opts);
|
|
113
|
+
|
|
114
|
+
// ...and the counter after it, AWAITED: two calls in quick succession must both be counted
|
|
115
|
+
// before the second's ceiling check reads the total, or the limit is enforced against a stale one.
|
|
116
|
+
await meter.record(cfg, result, { ticketId: opts.ticketId });
|
|
117
|
+
|
|
118
|
+
log.info(`AI: ${cfg.provider}/${result.model} ${result.usage.total} tokens in ${result.ms}ms`);
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Is there a provider configured at all? Callers use this to decide whether to render a control. */
|
|
123
|
+
async function isEnabled() {
|
|
124
|
+
return (await resolveConfig()).enabled;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A short round trip for an admin "Test connection". NEVER THROWS — it reports what is wrong. */
|
|
128
|
+
async function test(cfgOverride) {
|
|
129
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
130
|
+
if (!cfg.enabled) return { ok: false, kind: 'disabled', error: 'No provider is selected.' };
|
|
131
|
+
try {
|
|
132
|
+
const r = await complete({
|
|
133
|
+
system: 'Reply with the single word: ready. Do not explain.',
|
|
134
|
+
messages: [{ role: 'user', content: 'ready?' }],
|
|
135
|
+
maxTokens: 512,
|
|
136
|
+
temperature: 0,
|
|
137
|
+
skipBudget: true // an admin diagnosing a provider must not be blocked by a full budget
|
|
138
|
+
}, cfg);
|
|
139
|
+
return {
|
|
140
|
+
ok: true, model: r.model, ms: r.ms, reply: (r.text || '').trim().slice(0, 60), usage: r.usage,
|
|
141
|
+
finishReason: r.finishReason || null, reasoned: !!r.reasonedFor
|
|
142
|
+
};
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof AiError) return { ok: false, kind: err.kind, error: err.message, retryable: err.retryable };
|
|
145
|
+
return { ok: false, kind: 'bad_response', error: err.message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What the server has loaded, for an admin model picker. Empty when it cannot say. */
|
|
150
|
+
async function listModels(cfgOverride) {
|
|
151
|
+
const cfg = cfgOverride || await resolveConfig();
|
|
152
|
+
if (!cfg.enabled) return [];
|
|
153
|
+
try {
|
|
154
|
+
return await PROVIDERS[cfg.provider].listModels(cfg);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The writing-assist engines are bound to this client's complete() so a caller gets config +
|
|
161
|
+
// budget + retry for free. Prompt framing is supplied per call by the app (content).
|
|
162
|
+
const boundPolish = (opts) => polish(complete, opts);
|
|
163
|
+
const boundGenerate = (opts) => generate(complete, opts);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
complete, isEnabled, test, listModels, withOneRetry,
|
|
167
|
+
polish: boundPolish, generate: boundGenerate,
|
|
168
|
+
facts, AiError, PROVIDERS, DEFAULTS
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
createAiClient,
|
|
174
|
+
PROVIDERS, DEFAULTS,
|
|
175
|
+
AiError, fromFetchFailure, redact,
|
|
176
|
+
facts,
|
|
177
|
+
// Default writing-op catalogues, so an app can build its menus without re-declaring them.
|
|
178
|
+
POLISH_MODES: require('./polish').MODES,
|
|
179
|
+
POLISH_TONES: require('./polish').TONES,
|
|
180
|
+
RETRY_AFTER_MS
|
|
181
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aria-framework/ai",
|
|
3
|
+
"description": "Aria App Framework — AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"error.js",
|
|
14
|
+
"facts.js",
|
|
15
|
+
"polish.js",
|
|
16
|
+
"generate.js",
|
|
17
|
+
"providers/openai-compatible.js",
|
|
18
|
+
"providers/anthropic.js",
|
|
19
|
+
"browser/ai-polish.js"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node test/smoke.js"
|
|
23
|
+
}
|
|
24
|
+
}
|