altalandi 0.6.1 → 0.6.2
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/README.md +21 -12
- package/auth.mjs +2 -4
- package/batch.mjs +190 -41
- package/client.d.ts +57 -0
- package/client.mjs +52 -13
- package/package.json +1 -1
- package/report.mjs +69 -12
- package/skill/SKILL.md +18 -11
- package/update.mjs +2 -0
package/README.md
CHANGED
|
@@ -107,8 +107,8 @@ altalandi --report --file draft.txt # also write a Markdown report to ./altal
|
|
|
107
107
|
| `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
|
|
108
108
|
| `--json` | off | print the full API response |
|
|
109
109
|
| `--diff` | off | print the text before and after, then each change with its reason, on `stderr` (coloured in a terminal; `NO_COLOR` turns colour off) |
|
|
110
|
-
| `--review` | off | print one Markdown block on `stdout`, made to be shown as it is: the verdict, the text before and after, each change with its reason, what the call cost and the balance and free
|
|
111
|
-
| `--no-store` | off | send `contribute: false`: this text is never kept. Without it, free use (calls covered by the free daily
|
|
110
|
+
| `--review` | off | print one Markdown block on `stdout`, made to be shown as it is: the verdict, the text before and after, each change with its reason, what the call cost and the balance and free characters left. Exits on the outcome: `0` rewritten and verified or no change needed, `3` refused or unverified, `4` out of credit |
|
|
111
|
+
| `--no-store` | off | send `contribute: false`: this text is never kept. Without it, free use (calls covered by the free daily characters) is kept with personal data scrubbed, to improve the service; paid use is kept only with consent ([privacy](https://altalandi.is/personuvernd#textabanki)) |
|
|
112
112
|
| `--out <file>` | | write the refined text to `<file>` (UTF-8); with `--review` the block names it when the text is shortened |
|
|
113
113
|
| `--report` | off | also write `./altalandi-reports/altalandi-<time>-<id>.md`: input, output, a table of every change (before, after, reason, layer), warnings and the verdict |
|
|
114
114
|
| `--report-dir <dir>` | | write the report to `<dir>` instead (implies `--report`) |
|
|
@@ -127,8 +127,11 @@ altalandi https://example.com/pricing --out pricing-is.md # one page → Markd
|
|
|
127
127
|
altalandi https://example.com/docs/ --site --max-pages 50 # a site → ./example.com-is/
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
Each passage (a paragraph, list item, table cell, heading or string) is
|
|
131
|
-
|
|
130
|
+
Each passage (a paragraph, list item, table cell, heading or string) is refined
|
|
131
|
+
and checked on its own. Short passages (up to 1,500 characters) are sent
|
|
132
|
+
together through `/v1/refine/batch`, up to 40 a request and 6,000 characters;
|
|
133
|
+
longer ones go one call each to `/v1/refine`. Everything outside the passages is
|
|
134
|
+
written back byte for byte:
|
|
132
135
|
|
|
133
136
|
| format | refined | never touched |
|
|
134
137
|
|--------|---------|---------------|
|
|
@@ -154,13 +157,19 @@ the same origin and under the URL's folder.
|
|
|
154
157
|
from the public tariff:
|
|
155
158
|
|
|
156
159
|
```text
|
|
157
|
-
5 files, 24 passages, about 851 characters (23
|
|
160
|
+
5 files, 24 passages, about 851 characters (23 to send: repeated passages are sent once).
|
|
161
|
+
1 request: 23 short passages together in 1 batch.
|
|
158
162
|
1 more file has nothing to refine and is copied unchanged.
|
|
159
|
-
|
|
163
|
+
Kostnaður: um 3.016 stafir (um 1 A4-síða), um 30 kr. ef greitt er af inneign.
|
|
164
|
+
Hvert kall telst minnst 250 stafir; margir stuttir textar í einu kalli minnst 130 stafir hver. Hver aðgangur fær ókeypis dagskammt af stöfum (`GET /v1/pricing`).
|
|
160
165
|
```
|
|
161
166
|
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
Billing is by character (stafir): every character sent, spaces and punctuation
|
|
168
|
+
included. In a batch each string is billed at least 130 characters, and the
|
|
169
|
+
250-character per-call minimum is paid once per request, not once per string:
|
|
170
|
+
short strings cost about half what one call each would. The summary and the
|
|
171
|
+
report show the characters actually billed. An older self-hosted engine without the batch endpoint gets one
|
|
172
|
+
call per passage (the run says so once). In a terminal it asks before sending; without one (CI,
|
|
164
173
|
agents) it sends nothing unless you pass `--yes`.
|
|
165
174
|
|
|
166
175
|
| flag | default | meaning |
|
|
@@ -169,7 +178,7 @@ agents) it sends nothing unless you pass `--yes`.
|
|
|
169
178
|
| `--dry-run` | off | list every passage and the estimate; send nothing, write nothing |
|
|
170
179
|
| `--yes`, `-y` | off | send without asking |
|
|
171
180
|
| `--max-chars <n>` | | files are sent in order until the next would go over n characters; the rest are neither sent nor written |
|
|
172
|
-
| `--concurrency <n>` | `2` |
|
|
181
|
+
| `--concurrency <n>` | `2` | requests at once, 1–4 |
|
|
173
182
|
| `--site` / `--max-pages <n>` | off / `20` | read a site from the URL, up to n pages |
|
|
174
183
|
| `--no-store` | off | keep none of the passages (free use is otherwise kept, scrubbed of personal data) |
|
|
175
184
|
| `--json` | off | a machine-readable summary of every file and passage on stdout |
|
|
@@ -255,6 +264,6 @@ the explicit verification opt-out and exits 0 after a successful API response.
|
|
|
255
264
|
|
|
256
265
|
MIT licensed. Calls are paid from the account's prepaid credit (a 500 kr trial,
|
|
257
266
|
then credit bought in the portal); a call the credit cannot cover exits with the
|
|
258
|
-
server's `402 insufficient_balance` message. The request's text and glossary fields are charged
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
server's `402 insufficient_balance` message. The request's text and glossary fields are charged by the character
|
|
268
|
+
(every Unicode character, spaces, punctuation and line breaks included) at the
|
|
269
|
+
effective billing-tier weight, with a minimum per call.
|
package/auth.mjs
CHANGED
|
@@ -211,10 +211,8 @@ export async function runWhoami(flags = {}, io = {}) {
|
|
|
211
211
|
const me = await res.json();
|
|
212
212
|
if (flags.json) out(JSON.stringify({ ...me, keySource: source }, null, 2));
|
|
213
213
|
else {
|
|
214
|
-
const {
|
|
215
|
-
const free =
|
|
216
|
-
? `ókeypis eftir í dag ${formatInt(me.freeUnitsLeftToday)} af ${formatInt(me.freeDailyUnits)} einingum`
|
|
217
|
-
: me.freeDailyUnits ? `ókeypis ${formatInt(me.freeDailyUnits)} einingar á dag` : "";
|
|
214
|
+
const { formatIsk, freeCharsText } = await import("./report.mjs");
|
|
215
|
+
const free = freeCharsText(null, me);
|
|
218
216
|
out([me.username ?? "(lykill án aðgangs)",
|
|
219
217
|
`inneign ${me.balanceIsk != null ? formatIsk(me.balanceIsk) : "—"}`,
|
|
220
218
|
free, `lykill ${me.keyId} (${source})`].filter(Boolean).join(" · "));
|
package/batch.mjs
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// The order of things is the safety story:
|
|
5
5
|
// 1. plan read every document and cut it into passages (segment.mjs,
|
|
6
6
|
// web.mjs); nothing is sent yet
|
|
7
|
-
// 2. estimate files, passages
|
|
8
|
-
// anything is sent
|
|
7
|
+
// 2. estimate files, passages and the characters billed, with the price,
|
|
8
|
+
// printed before anything is sent
|
|
9
9
|
// 3. consent a y/N question in a terminal, or --yes; with neither, nothing
|
|
10
10
|
// is sent (--dry-run stops here on purpose)
|
|
11
|
-
// 4. refine
|
|
12
|
-
//
|
|
11
|
+
// 4. refine short passages go together in batch requests (/v1/refine/batch),
|
|
12
|
+
// longer ones one call each; 2 requests at a time by default; a
|
|
13
|
+
// 429 waits for Retry-After, a 402 stops the run cleanly
|
|
13
14
|
// 5. write each file once all its passages are back; a passage whose
|
|
14
15
|
// meaning check did not pass keeps its original text; nothing is
|
|
15
16
|
// ever written inside the input
|
|
@@ -22,8 +23,8 @@ import { existsSync, statSync } from "node:fs";
|
|
|
22
23
|
import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
23
24
|
import path from "node:path";
|
|
24
25
|
|
|
25
|
-
import { refine } from "./client.mjs";
|
|
26
|
-
import { writeBatchReport } from "./report.mjs";
|
|
26
|
+
import { refine, refineBatch } from "./client.mjs";
|
|
27
|
+
import { CHARS_PER_WORD, charsEquivalent, formatChars, formatIsk, writeBatchReport } from "./report.mjs";
|
|
27
28
|
import { assemble, FORMATS, isConfigJson, repairCodeSpans, segment, segmentMarkdown, splitForApi } from "./segment.mjs";
|
|
28
29
|
import { pagePath, readPage, readSite, safeSegment, userAgent } from "./web.mjs";
|
|
29
30
|
|
|
@@ -39,6 +40,18 @@ const MAX_RETRY_WAIT_MS = 5 * 60_000; // a longer Retry-After means "come back l
|
|
|
39
40
|
const MAX_RATE_LIMIT_RETRIES = 8;
|
|
40
41
|
const MAX_TRANSIENT_RETRIES = 2;
|
|
41
42
|
const ERRORS_IN_A_ROW_TO_STOP = 3;
|
|
43
|
+
// Short passages go to /v1/refine/batch: each is billed at least 130 characters
|
|
44
|
+
// there instead of the 250-character call minimum, which is paid once per
|
|
45
|
+
// request. A request
|
|
46
|
+
// stays small enough to come back well inside the web proxy's ~300 s timeout
|
|
47
|
+
// (the server refines 4 at a time: ~40 short items take a minute or two).
|
|
48
|
+
export const BATCH_PASSAGE_MAX_CHARS = 1500;
|
|
49
|
+
export const BATCH_MAX_ITEMS = 40;
|
|
50
|
+
export const BATCH_MAX_CHARS = 6000;
|
|
51
|
+
// When /v1/pricing says neither (minimumBillableChars / batchItemMinimumChars)
|
|
52
|
+
// nor the word units of an older engine.
|
|
53
|
+
const MIN_BILLABLE_CHARS = 250;
|
|
54
|
+
const BATCH_ITEM_MIN_CHARS = 130;
|
|
42
55
|
const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
|
|
43
56
|
const REGISTERS = ["formal", "neutral", "warm"];
|
|
44
57
|
|
|
@@ -60,10 +73,9 @@ const preview = (s, n = 72) => {
|
|
|
60
73
|
return one.length > n ? `${one.slice(0, n - 1)}…` : one;
|
|
61
74
|
};
|
|
62
75
|
|
|
63
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
const
|
|
66
|
-
.reduce((n, t) => n + Math.max(1, Math.ceil(t.length / 20)), 0);
|
|
76
|
+
/** Billable characters: every Unicode code point of the trimmed text, spaces,
|
|
77
|
+
* punctuation and line breaks included (the server's rule). */
|
|
78
|
+
export const billableChars = (text) => [...String(text).trim()].length;
|
|
67
79
|
|
|
68
80
|
/** The real path of p, or of its deepest existing ancestor plus the rest, so a
|
|
69
81
|
* symlinked --out is compared where it really points. */
|
|
@@ -222,6 +234,31 @@ function buildCalls(files, maxChars) {
|
|
|
222
234
|
return [...calls.values()];
|
|
223
235
|
}
|
|
224
236
|
|
|
237
|
+
/** The requests of a run: short calls together in batches of at most
|
|
238
|
+
* BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone. */
|
|
239
|
+
export function groupCalls(calls) {
|
|
240
|
+
const jobs = [];
|
|
241
|
+
let open = null;
|
|
242
|
+
for (const call of calls) {
|
|
243
|
+
if (call.text.length > BATCH_PASSAGE_MAX_CHARS) { jobs.push({ batch: false, calls: [call] }); continue; }
|
|
244
|
+
if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
|
|
245
|
+
if (!open) { open = { batch: true, calls: [], chars: 0 }; jobs.push(open); }
|
|
246
|
+
open.calls.push(call);
|
|
247
|
+
open.chars += call.text.length;
|
|
248
|
+
}
|
|
249
|
+
return jobs;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Characters a request is billed: the call minimum for one text; for a batch,
|
|
253
|
+
* each item at least the item minimum, the call minimum once for the request. */
|
|
254
|
+
function jobChars(job, pricing) {
|
|
255
|
+
if (!job.batch) return Math.max(pricing.minChars, billableChars(job.calls[0].text));
|
|
256
|
+
const items = job.calls.reduce((n, c) => n + Math.max(pricing.itemMinChars, billableChars(c.text)), 0);
|
|
257
|
+
return Math.max(pricing.minChars, items);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fin = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
261
|
+
|
|
225
262
|
async function fetchPricing(baseUrl, fetchImpl) {
|
|
226
263
|
const controller = new AbortController();
|
|
227
264
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
@@ -230,12 +267,20 @@ async function fetchPricing(baseUrl, fetchImpl) {
|
|
|
230
267
|
if (!res.ok) return null;
|
|
231
268
|
const p = JSON.parse(await res.text());
|
|
232
269
|
const lane = p?.tiers?.standard?.fast;
|
|
233
|
-
if (!
|
|
270
|
+
if (!lane || typeof lane !== "object") return null;
|
|
271
|
+
// Characters (billingUnit "character"); an engine from before the switch
|
|
272
|
+
// gives word units, converted with charsPerWord.
|
|
273
|
+
const cpw = fin(p.charsPerWord) > 0 ? p.charsPerWord : CHARS_PER_WORD;
|
|
274
|
+
const chars = (c, u) => fin(p[c]) ?? (fin(p[u]) != null ? Math.round(p[u] * cpw) : null);
|
|
275
|
+
// The tier's price already carries its weight, as iskPer1000Units did.
|
|
276
|
+
const per100 = fin(lane.iskPer100Chars) ?? (fin(lane.iskPer1000Units) != null ? lane.iskPer1000Units / 10 / cpw : null);
|
|
234
277
|
return {
|
|
235
|
-
|
|
278
|
+
minChars: chars("minimumBillableChars", "minimumBillableUnits") ?? MIN_BILLABLE_CHARS,
|
|
279
|
+
itemMinChars: chars("batchItemMinimumChars", "batchItemMinimumUnits") ?? BATCH_ITEM_MIN_CHARS,
|
|
236
280
|
multiplier: Number(lane.multiplier) || 1,
|
|
237
|
-
|
|
238
|
-
freeDaily:
|
|
281
|
+
iskPerChar: per100 != null ? per100 / 100 : null,
|
|
282
|
+
freeDaily: chars("accountFreeDailyChars", "accountFreeDailyUnits") ?? 0,
|
|
283
|
+
charsPerWord: cpw,
|
|
239
284
|
};
|
|
240
285
|
} catch {
|
|
241
286
|
return null;
|
|
@@ -244,14 +289,22 @@ async function fetchPricing(baseUrl, fetchImpl) {
|
|
|
244
289
|
}
|
|
245
290
|
}
|
|
246
291
|
|
|
247
|
-
function estimateLines(plan, calls, pricing, opts) {
|
|
292
|
+
function estimateLines(plan, calls, jobs, pricing, opts) {
|
|
248
293
|
const refining = plan.files.filter((f) => f.status === "refine");
|
|
249
294
|
const passages = refining.reduce((n, f) => n + f.units.length, 0);
|
|
250
295
|
const chars = calls.reduce((n, c) => n + c.text.length, 0);
|
|
251
296
|
const lines = [
|
|
252
297
|
`${plural(refining.length, "file", "files")}, ${plural(passages, "passage", "passages")}, about ${fmt(chars)} characters`
|
|
253
|
-
+ `${calls.length !== passages ? ` (${
|
|
298
|
+
+ `${calls.length !== passages ? ` (${fmt(calls.length)} to send: repeated passages are sent once)` : ""}.`,
|
|
254
299
|
];
|
|
300
|
+
const batches = jobs.filter((j) => j.batch);
|
|
301
|
+
if (calls.length) {
|
|
302
|
+
const together = batches.reduce((n, j) => n + j.calls.length, 0);
|
|
303
|
+
const alone = jobs.length - batches.length;
|
|
304
|
+
lines.push(`${plural(jobs.length, "request", "requests")}: `
|
|
305
|
+
+ [together && `${plural(together, "short passage", "short passages")} together in ${plural(batches.length, "batch", "batches")}`,
|
|
306
|
+
alone && `${plural(alone, "longer passage", "longer passages")} one by one`].filter(Boolean).join(", ") + ".");
|
|
307
|
+
}
|
|
255
308
|
const copied = plan.files.filter((f) => f.status === "copy").length;
|
|
256
309
|
if (copied) lines.push(`${plural(copied, "more file has", "more files have")} nothing to refine and ${copied === 1 ? "is" : "are"} copied unchanged.`);
|
|
257
310
|
const over = plan.files.filter((f) => f.status === "over-budget");
|
|
@@ -260,10 +313,16 @@ function estimateLines(plan, calls, pricing, opts) {
|
|
|
260
313
|
+ `(from ${over[0].path} on).`);
|
|
261
314
|
}
|
|
262
315
|
if (pricing && calls.length) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
316
|
+
// In Icelandic, as on the site: characters, one equivalent and krónur;
|
|
317
|
+
// never the rate per 100 characters.
|
|
318
|
+
const raw = jobs.reduce((n, j) => n + jobChars(j, pricing), 0);
|
|
319
|
+
const billed = raw * pricing.multiplier;
|
|
320
|
+
const isk = pricing.iskPerChar != null ? raw * pricing.iskPerChar : null;
|
|
321
|
+
const price = isk != null ? `, um ${formatIsk(isk >= 10 ? Math.round(isk) : isk)} ef greitt er af inneign` : "";
|
|
322
|
+
lines.push(`Kostnaður: um ${formatChars(billed)} (${charsEquivalent(billed)})${price}.`);
|
|
323
|
+
const rule = `Hvert kall telst minnst ${formatChars(pricing.minChars)}`
|
|
324
|
+
+ (batches.length ? `; margir stuttir textar í einu kalli minnst ${formatChars(pricing.itemMinChars)} hver.` : ".");
|
|
325
|
+
lines.push(rule + (pricing.freeDaily ? ` ${formatChars(pricing.freeDaily)} á dag eru ókeypis.` : ""));
|
|
267
326
|
}
|
|
268
327
|
return lines;
|
|
269
328
|
}
|
|
@@ -369,6 +428,7 @@ export async function runBatch(opts, io = {}) {
|
|
|
369
428
|
return EXIT_ERROR;
|
|
370
429
|
}
|
|
371
430
|
const calls = buildCalls(plan.files, opts.maxChars);
|
|
431
|
+
const jobs = groupCalls(calls);
|
|
372
432
|
const baseUrl = (opts.baseUrl || "https://altalandi.is/api").replace(/\/+$/, "");
|
|
373
433
|
const where = `${plan.label} → ${display(plan.output, cwd)}`;
|
|
374
434
|
const refining = () => plan.files.filter((f) => f.status === "refine");
|
|
@@ -379,7 +439,7 @@ export async function runBatch(opts, io = {}) {
|
|
|
379
439
|
}
|
|
380
440
|
// The public tariff: no key, no text sent, nothing charged.
|
|
381
441
|
const pricing = calls.length ? await fetchPricing(baseUrl, fetchImpl) : null;
|
|
382
|
-
const estimate = estimateLines(plan, calls, pricing, opts);
|
|
442
|
+
const estimate = estimateLines(plan, calls, jobs, pricing, opts);
|
|
383
443
|
|
|
384
444
|
if (opts.dryRun) {
|
|
385
445
|
if (opts.json) {
|
|
@@ -434,7 +494,18 @@ export async function runBatch(opts, io = {}) {
|
|
|
434
494
|
await writeFile(target, data);
|
|
435
495
|
};
|
|
436
496
|
|
|
437
|
-
const state = { stop: null, pausedUntil: 0, errorsInARow: 0 };
|
|
497
|
+
const state = { stop: null, pausedUntil: 0, errorsInARow: 0, noBatch: false, billed: null, billedChars: null, approx: false };
|
|
498
|
+
// What a response says it billed: characters, or the word units of an
|
|
499
|
+
// engine from before the switch (converted, and marked as approximate).
|
|
500
|
+
const bill = (data) => {
|
|
501
|
+
const b = data?.billing || {};
|
|
502
|
+
const units = fin(b.units) ?? fin(data?.units);
|
|
503
|
+
if (units != null) state.billed = (state.billed ?? 0) + units;
|
|
504
|
+
const chars = fin(b.characters) ?? fin(data?.characters);
|
|
505
|
+
if (chars == null && units == null) return;
|
|
506
|
+
if (chars == null) state.approx = true;
|
|
507
|
+
state.billedChars = (state.billedChars ?? 0) + (chars ?? units * (pricing?.charsPerWord ?? CHARS_PER_WORD));
|
|
508
|
+
};
|
|
438
509
|
const stop = (reason, message) => { if (!state.stop) state.stop = { reason, message }; };
|
|
439
510
|
|
|
440
511
|
for (const f of plan.files.filter((x) => x.status === "copy")) {
|
|
@@ -453,15 +524,19 @@ export async function runBatch(opts, io = {}) {
|
|
|
453
524
|
for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
|
|
454
525
|
};
|
|
455
526
|
|
|
456
|
-
|
|
527
|
+
// One request with the run's retry rules: `send` is one /v1/refine or one
|
|
528
|
+
// /v1/refine/batch call. A batch endpoint the server does not have (404/405,
|
|
529
|
+
// an older self-hosted engine) answers { unsupported }.
|
|
530
|
+
const attempt = async (send, { batch = false } = {}) => {
|
|
457
531
|
let rateLimited = 0;
|
|
458
532
|
let transient = 0;
|
|
459
533
|
for (;;) {
|
|
460
534
|
await pause();
|
|
461
535
|
if (state.stop) return { skipped: true };
|
|
462
536
|
try {
|
|
463
|
-
return { data: await
|
|
537
|
+
return { data: await send() };
|
|
464
538
|
} catch (e) {
|
|
539
|
+
if (batch && (e.status === 404 || e.status === 405)) return { unsupported: true };
|
|
465
540
|
if (e.status === 429) {
|
|
466
541
|
rateLimited++;
|
|
467
542
|
const ms = Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : Math.min(60_000, 2000 * 2 ** (rateLimited - 1));
|
|
@@ -515,32 +590,97 @@ export async function runBatch(opts, io = {}) {
|
|
|
515
590
|
}
|
|
516
591
|
};
|
|
517
592
|
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
593
|
+
const countError = (error) => {
|
|
594
|
+
state.errorsInARow++;
|
|
595
|
+
if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${error}`);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const done = async (job) => {
|
|
599
|
+
if (job.calls.some((c) => c.result?.skipped)) return;
|
|
600
|
+
if (job.failed) countError(job.failed); // a whole request failed: one error, not one per passage
|
|
601
|
+
else {
|
|
602
|
+
for (const call of job.calls) {
|
|
603
|
+
if (call.result.error) countError(call.result.error);
|
|
604
|
+
else state.errorsInARow = 0;
|
|
605
|
+
}
|
|
525
606
|
}
|
|
526
|
-
for (const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
607
|
+
for (const call of job.calls) {
|
|
608
|
+
for (const unit of call.units) {
|
|
609
|
+
if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
|
|
610
|
+
settle(unit);
|
|
611
|
+
const file = unit.file;
|
|
612
|
+
if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
|
|
613
|
+
}
|
|
531
614
|
}
|
|
532
615
|
};
|
|
533
616
|
|
|
617
|
+
const single = async (call) => {
|
|
618
|
+
const r = await attempt(() => refine(call.text, requestOptions));
|
|
619
|
+
if (r.data) bill(r.data);
|
|
620
|
+
return r;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/** One batch request; every item is mapped back to its call by id. */
|
|
624
|
+
const sendBatch = async (job) => {
|
|
625
|
+
const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
|
|
626
|
+
const r = await attempt(() => refineBatch(items, requestOptions), { batch: true });
|
|
627
|
+
if (!r.data) return r;
|
|
628
|
+
const data = r.data;
|
|
629
|
+
bill(data);
|
|
630
|
+
const byId = new Map(data.items.map((it) => [String(it.id), it]));
|
|
631
|
+
const artifacts = data.quality?.encodingArtifacts || [];
|
|
632
|
+
job.calls.forEach((call, k) => {
|
|
633
|
+
const item = byId.get(String(k));
|
|
634
|
+
if (!item) call.result = { error: "the batch response has no result for this passage" };
|
|
635
|
+
else if (item.error) call.result = { error: String(item.error) };
|
|
636
|
+
else {
|
|
637
|
+
// The batch's quality block covers every item; keep the mojibake guard per item.
|
|
638
|
+
const own = artifacts.filter((a) => a && String(item.output).includes(a));
|
|
639
|
+
call.result = { data: { ...item, id: data.id || undefined, quality: { encodingArtifacts: own } } };
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
return { done: true };
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const alone = (job) => job.calls.map((call) => ({ batch: false, calls: [call] }));
|
|
534
646
|
const hadWork = refining().length > 0;
|
|
535
|
-
const queue = [...
|
|
647
|
+
const queue = [...jobs];
|
|
536
648
|
const worker = async () => {
|
|
537
649
|
while (queue.length && !state.stop) {
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
650
|
+
const job = queue.shift();
|
|
651
|
+
if (job.batch && state.noBatch) { queue.unshift(...alone(job)); continue; }
|
|
652
|
+
if (!job.batch) {
|
|
653
|
+
job.calls[0].result = await single(job.calls[0]);
|
|
654
|
+
} else {
|
|
655
|
+
const r = await sendBatch(job);
|
|
656
|
+
if (r.unsupported) {
|
|
657
|
+
if (!state.noBatch) {
|
|
658
|
+
state.noBatch = true;
|
|
659
|
+
log("Note: this server has no batch endpoint (/v1/refine/batch), so every passage is sent on its own "
|
|
660
|
+
+ `(each call is billed at least ${fmt(pricing?.minChars ?? MIN_BILLABLE_CHARS)} characters).`);
|
|
661
|
+
}
|
|
662
|
+
queue.unshift(...alone(job));
|
|
663
|
+
fill(); // one batch became many calls: use every worker for them
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (!r.done) {
|
|
667
|
+
for (const call of job.calls) call.result = r;
|
|
668
|
+
if (r.error) job.failed = r.error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
await done(job);
|
|
541
672
|
}
|
|
542
673
|
};
|
|
543
|
-
|
|
674
|
+
const running = [];
|
|
675
|
+
let active = 0;
|
|
676
|
+
function fill() {
|
|
677
|
+
while (active < concurrency && queue.length && !state.stop) {
|
|
678
|
+
active++;
|
|
679
|
+
running.push(worker().finally(() => { active--; }));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
fill();
|
|
683
|
+
while (running.length) await Promise.all(running.splice(0));
|
|
544
684
|
|
|
545
685
|
for (const f of plan.files) if (f.status === "refine") f.status = "unfinished";
|
|
546
686
|
|
|
@@ -576,6 +716,12 @@ export async function runBatch(opts, io = {}) {
|
|
|
576
716
|
kept: count("kept"),
|
|
577
717
|
failed: count("failed"),
|
|
578
718
|
characters: calls.filter((c) => c.result && !c.result.skipped).reduce((n, c) => n + c.text.length, 0),
|
|
719
|
+
// What the server says it billed, summed over the responses (null: none
|
|
720
|
+
// said). billedCharacters is what is shown; units is the word equivalent.
|
|
721
|
+
billedCharacters: state.billedChars == null ? null
|
|
722
|
+
: state.approx ? Math.round(state.billedChars / 10) * 10 : Math.round(state.billedChars),
|
|
723
|
+
...(state.approx ? { billedCharactersApprox: true } : {}),
|
|
724
|
+
units: state.billed,
|
|
579
725
|
},
|
|
580
726
|
...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
|
|
581
727
|
files,
|
|
@@ -612,6 +758,9 @@ export async function runBatch(opts, io = {}) {
|
|
|
612
758
|
log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
|
|
613
759
|
+ `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
|
|
614
760
|
+ (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
|
|
761
|
+
if (t.billedCharacters != null) {
|
|
762
|
+
log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
|
|
763
|
+
}
|
|
615
764
|
if (t.kept) log(`${plural(t.kept, "passage keeps", "passages keep")} the original text because a check did not pass; the report lists ${t.kept === 1 ? "it" : "them"}.`);
|
|
616
765
|
if (t.overBudget) log(`${plural(t.overBudget, "file was", "files were")} left out for --max-chars.`);
|
|
617
766
|
if (summary.report) log(`Report: ${display(summary.report, cwd)}`);
|
package/client.d.ts
CHANGED
|
@@ -87,6 +87,10 @@ export interface RefineResult {
|
|
|
87
87
|
warnings: string[];
|
|
88
88
|
/** true when a test key made the call: no model ran, nothing was charged, output = input. */
|
|
89
89
|
test?: boolean | null;
|
|
90
|
+
/** Characters billed (= billing.characters); absent from engines before character billing. */
|
|
91
|
+
characters?: number;
|
|
92
|
+
/** /v1 only: what the call cost and what is left (characters, chargedIsk, balanceIsk, freeCharsLeftToday, …). */
|
|
93
|
+
billing?: Record<string, unknown> | null;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
export type VerifiedRefineResult = RefineResult & { meaningPreserved: true };
|
|
@@ -113,6 +117,59 @@ export class MeaningVerificationError extends AltalandiError {
|
|
|
113
117
|
/** Refine text and return the full hosted API response. */
|
|
114
118
|
export function refine(text: string, options?: RefineOptions): Promise<RefineResult>;
|
|
115
119
|
|
|
120
|
+
export interface BatchItem {
|
|
121
|
+
/** Your key for the string; defaults to its index, as a string. */
|
|
122
|
+
id?: string;
|
|
123
|
+
/** At most 2,000 characters. */
|
|
124
|
+
text: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface BatchOptions {
|
|
128
|
+
sourceLang?: SourceLang;
|
|
129
|
+
domain?: Domain;
|
|
130
|
+
register?: Register;
|
|
131
|
+
preserveMeaning?: boolean;
|
|
132
|
+
scoreNativeness?: boolean;
|
|
133
|
+
onlyImprove?: boolean;
|
|
134
|
+
glossary?: GlossaryTerm[];
|
|
135
|
+
autoTerminology?: boolean;
|
|
136
|
+
contribute?: boolean;
|
|
137
|
+
apiKey?: string;
|
|
138
|
+
baseUrl?: string;
|
|
139
|
+
timeoutMs?: number;
|
|
140
|
+
fetch?: FetchLike;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface BatchItemResult {
|
|
144
|
+
id: string;
|
|
145
|
+
output: string;
|
|
146
|
+
sourceLang: "en" | "is";
|
|
147
|
+
diffs: RefineDiff[];
|
|
148
|
+
meaningCheck: "passed" | "changed" | "reverted" | "skipped" | "unavailable";
|
|
149
|
+
meaningPreserved: boolean | null;
|
|
150
|
+
warnings: string[];
|
|
151
|
+
/** true: refined in one model call with other strings, then checked on its own. */
|
|
152
|
+
packed: boolean;
|
|
153
|
+
/** Set when this string could not be refined: it came back unchanged and was not billed. */
|
|
154
|
+
error?: string | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface BatchResult {
|
|
158
|
+
id: string;
|
|
159
|
+
runUrl: string | null;
|
|
160
|
+
items: BatchItemResult[];
|
|
161
|
+
/** Characters billed for the whole batch (= billing.characters); absent from engines before character billing. */
|
|
162
|
+
characters?: number;
|
|
163
|
+
/** @deprecated The word equivalent of `characters` (= billing.units). */
|
|
164
|
+
units: number;
|
|
165
|
+
billing: Record<string, unknown> | null;
|
|
166
|
+
quality: RefineQuality;
|
|
167
|
+
warnings: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Refine many short strings in one call (POST /v1/refine/batch). */
|
|
171
|
+
export function refineBatch(items: BatchItem[], options?: BatchOptions): Promise<BatchResult>;
|
|
172
|
+
|
|
116
173
|
/** Refine text and throw unless meaningPreserved is strict true. */
|
|
117
174
|
export function refineVerified(text: string, options?: RefineOptions): Promise<VerifiedRefineResult>;
|
|
118
175
|
|
package/client.mjs
CHANGED
|
@@ -14,6 +14,18 @@ const REQUEST_FIELDS = [
|
|
|
14
14
|
"returnDiffs",
|
|
15
15
|
"contribute",
|
|
16
16
|
];
|
|
17
|
+
// /v1/refine/batch takes fewer options: no writer/scoring tiers, polish or diffs switch.
|
|
18
|
+
const BATCH_REQUEST_FIELDS = [
|
|
19
|
+
"sourceLang",
|
|
20
|
+
"domain",
|
|
21
|
+
"register",
|
|
22
|
+
"preserveMeaning",
|
|
23
|
+
"scoreNativeness",
|
|
24
|
+
"onlyImprove",
|
|
25
|
+
"glossary",
|
|
26
|
+
"autoTerminology",
|
|
27
|
+
"contribute",
|
|
28
|
+
];
|
|
17
29
|
|
|
18
30
|
export class AltalandiError extends Error {
|
|
19
31
|
constructor(message, { code = "ALTALANDI_ERROR", status, retryAfterMs } = {}) {
|
|
@@ -80,16 +92,10 @@ function requestBody(text, options) {
|
|
|
80
92
|
return body;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
|
-
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
*/
|
|
88
|
-
export async function refine(text, options = {}) {
|
|
89
|
-
if (typeof text !== "string" || !text.trim()) {
|
|
90
|
-
throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
|
|
91
|
-
}
|
|
92
|
-
|
|
95
|
+
/** POST a JSON body to `${baseUrl}${route}` with the key, base URL, timeout and
|
|
96
|
+
* error handling every call shares. Returns the payload when `validShape`
|
|
97
|
+
* accepts it; throws an AltalandiError (with status and Retry-After) otherwise. */
|
|
98
|
+
async function post(route, body, options, validShape) {
|
|
93
99
|
const explicitKey = options.apiKey;
|
|
94
100
|
const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
|
|
95
101
|
if (selectedKey !== undefined && typeof selectedKey !== "string") {
|
|
@@ -129,13 +135,13 @@ export async function refine(text, options = {}) {
|
|
|
129
135
|
try {
|
|
130
136
|
let response;
|
|
131
137
|
try {
|
|
132
|
-
response = await fetchImpl(`${baseUrl}
|
|
138
|
+
response = await fetchImpl(`${baseUrl}${route}`, {
|
|
133
139
|
method: "POST",
|
|
134
140
|
headers: {
|
|
135
141
|
"content-type": "application/json",
|
|
136
142
|
authorization: `Bearer ${apiKey}`,
|
|
137
143
|
},
|
|
138
|
-
body: JSON.stringify(
|
|
144
|
+
body: JSON.stringify(body),
|
|
139
145
|
signal: controller.signal,
|
|
140
146
|
});
|
|
141
147
|
} catch {
|
|
@@ -190,7 +196,7 @@ export async function refine(text, options = {}) {
|
|
|
190
196
|
);
|
|
191
197
|
}
|
|
192
198
|
|
|
193
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
|
|
199
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload) || !validShape(payload)) {
|
|
194
200
|
throw new AltalandiError("Altalandi returned an invalid response shape.", {
|
|
195
201
|
code: "INVALID_RESPONSE",
|
|
196
202
|
status: response.status,
|
|
@@ -203,6 +209,39 @@ export async function refine(text, options = {}) {
|
|
|
203
209
|
}
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Refine English or Icelandic text through the hosted Altalandi API.
|
|
214
|
+
* Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
|
|
215
|
+
* the JSON body. The full API result is returned without weakening its verdict.
|
|
216
|
+
*/
|
|
217
|
+
export async function refine(text, options = {}) {
|
|
218
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
219
|
+
throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
|
|
220
|
+
}
|
|
221
|
+
return post("/v1/refine", requestBody(text, options), options, (p) => typeof p.output === "string");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Refine many short strings in one call (POST /v1/refine/batch): at most 500
|
|
226
|
+
* items of at most 2,000 characters each, 20,000 characters together. Each item
|
|
227
|
+
* comes back with its own output and meaning verdict; an item with `error` came
|
|
228
|
+
* back unchanged and was not billed. Same key, limits and errors as refine().
|
|
229
|
+
*/
|
|
230
|
+
export async function refineBatch(items, options = {}) {
|
|
231
|
+
if (!Array.isArray(items) || !items.length
|
|
232
|
+
|| !items.every((it) => it && typeof it.text === "string" && (it.id === undefined || typeof it.id === "string"))) {
|
|
233
|
+
throw new AltalandiError("Altalandi refineBatch requires a non-empty list of { id?, text } items.", {
|
|
234
|
+
code: "INVALID_INPUT",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const body = { items: items.map((it) => (it.id === undefined ? { text: it.text } : { id: it.id, text: it.text })) };
|
|
238
|
+
for (const field of BATCH_REQUEST_FIELDS) {
|
|
239
|
+
if (options[field] !== undefined) body[field] = options[field];
|
|
240
|
+
}
|
|
241
|
+
return post("/v1/refine/batch", body, options, (p) => Array.isArray(p.items)
|
|
242
|
+
&& p.items.every((it) => it && typeof it === "object" && typeof it.output === "string"));
|
|
243
|
+
}
|
|
244
|
+
|
|
206
245
|
/** Assert that an existing result carries a completed, positive meaning check. */
|
|
207
246
|
export function assertMeaningVerified(result) {
|
|
208
247
|
if (result?.meaningPreserved !== true) {
|
package/package.json
CHANGED
package/report.mjs
CHANGED
|
@@ -154,6 +154,64 @@ export function formatIsk(n) {
|
|
|
154
154
|
|
|
155
155
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
156
156
|
|
|
157
|
+
// ── characters (stafir) ─────────────────────────────────────────────────────
|
|
158
|
+
// Billing is in characters. Engines from before the switch send only word
|
|
159
|
+
// units; those are shown as characters too (units × charsPerWord, rounded to
|
|
160
|
+
// tens and marked "um"). The per-100-characters rate is never shown: amounts
|
|
161
|
+
// in krónur and friendly equivalents (an A4 page) only.
|
|
162
|
+
|
|
163
|
+
/** Characters per word, when an engine does not say (`charsPerWord`). */
|
|
164
|
+
export const CHARS_PER_WORD = 6.5;
|
|
165
|
+
/** A normal A4 page (display only). */
|
|
166
|
+
export const CHARS_PER_A4_PAGE = 3200;
|
|
167
|
+
|
|
168
|
+
const singular = (n) => { const k = Math.round(Math.abs(n)); return k % 10 === 1 && k % 100 !== 11; };
|
|
169
|
+
|
|
170
|
+
/** 2690 → "2.690 stafir", 21 → "21 stafur". */
|
|
171
|
+
export function formatChars(n) {
|
|
172
|
+
return `${formatInt(n)} ${singular(n) ? "stafur" : "stafir"}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One friendly equivalent: "um 7 A4-síður", "um 1 A4-síða", "innan við hálfa A4-síðu". */
|
|
176
|
+
export function charsEquivalent(n) {
|
|
177
|
+
const pages = Math.round(Number(n) / CHARS_PER_A4_PAGE);
|
|
178
|
+
if (pages < 1) return "innan við hálfa A4-síðu";
|
|
179
|
+
return `um ${formatInt(pages)} ${singular(pages) ? "A4-síða" : "A4-síður"}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Word units of an older engine as characters, rounded to tens. */
|
|
183
|
+
export function unitsToChars(units, charsPerWord = CHARS_PER_WORD) {
|
|
184
|
+
return Math.round((Number(units) * charsPerWord) / 10) * 10;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const perWord = (...from) => from.map((o) => num(o?.charsPerWord)).find((v) => v != null && v > 0) ?? CHARS_PER_WORD;
|
|
188
|
+
|
|
189
|
+
/** The first source with a characters field wins; else the first with word
|
|
190
|
+
* units, converted. → { n, approx } or null. */
|
|
191
|
+
function charsOf(sources, charsKey, unitsKey, cpw) {
|
|
192
|
+
for (const s of sources) if (num(s?.[charsKey]) != null) return { n: s[charsKey], approx: false };
|
|
193
|
+
for (const s of sources) if (num(s?.[unitsKey]) != null) return { n: unitsToChars(s[unitsKey], cpw), approx: true };
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Today's free allowance: "ókeypis eftir í dag 9.800 af 15.000 stöfum".
|
|
198
|
+
* `billing` is a call's billing block, `after`/`before` the /v1/me answers
|
|
199
|
+
* after and before it; any may be null. "" when nothing is known. */
|
|
200
|
+
export function freeCharsText(billing, after = null, before = null) {
|
|
201
|
+
const cpw = perWord(billing, after, before);
|
|
202
|
+
const left = charsOf([billing, after], "freeCharsLeftToday", "freeUnitsLeftToday", cpw);
|
|
203
|
+
const daily = charsOf([after, before], "freeDailyChars", "freeDailyUnits", cpw);
|
|
204
|
+
// One "um", before the first converted number that is not 0.
|
|
205
|
+
const um = (c) => (c?.approx && c.n ? "um " : "");
|
|
206
|
+
if (left && daily) {
|
|
207
|
+
const [a, b] = um(left) ? ["um ", ""] : ["", um(daily)];
|
|
208
|
+
return `ókeypis eftir í dag ${a}${formatInt(left.n)} af ${b}${formatInt(daily.n)} ${singular(daily.n) ? "staf" : "stöfum"}`;
|
|
209
|
+
}
|
|
210
|
+
if (left) return `ókeypis eftir í dag ${um(left)}${formatChars(left.n)}`;
|
|
211
|
+
if (daily && daily.n > 0) return `ókeypis ${um(daily)}${formatChars(daily.n)} á dag`;
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
|
|
157
215
|
/**
|
|
158
216
|
* What the call cost and what is left. `data.billing` (sent by newer engines)
|
|
159
217
|
* is exact; otherwise the cost is the balance difference between /v1/me
|
|
@@ -167,24 +225,21 @@ export function costLines(data, before, after) {
|
|
|
167
225
|
if (charged == null && num(before?.balanceIsk) != null && num(after?.balanceIsk) != null) {
|
|
168
226
|
charged = Math.round((before.balanceIsk - after.balanceIsk) * 100) / 100;
|
|
169
227
|
}
|
|
170
|
-
const
|
|
171
|
-
const
|
|
228
|
+
const chars = charsOf([b, data], "characters", "units", perWord(b, data, after, before));
|
|
229
|
+
const charsText = chars ? ` · ${chars.approx ? "um " : ""}${formatChars(chars.n)}` : "";
|
|
172
230
|
const lines = [];
|
|
173
|
-
if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${
|
|
174
|
-
else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${
|
|
231
|
+
if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${charsText}`);
|
|
232
|
+
else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${charsText}`);
|
|
175
233
|
else if (charged === 0) lines.push(b.paidFrom === "credit"
|
|
176
|
-
? `- Þetta kall: 0 kr. af inneign${
|
|
177
|
-
: `- Þetta kall: 0 kr., ókeypis dagskammtur${
|
|
178
|
-
else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${
|
|
234
|
+
? `- Þetta kall: 0 kr. af inneign${charsText}`
|
|
235
|
+
: `- Þetta kall: 0 kr., ókeypis dagskammtur${charsText}`);
|
|
236
|
+
else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${charsText}`);
|
|
179
237
|
|
|
180
238
|
const balance = num(b.balanceIsk) ?? num(after?.balanceIsk);
|
|
181
|
-
const
|
|
182
|
-
const daily = num(after?.freeDailyUnits) ?? num(before?.freeDailyUnits);
|
|
239
|
+
const free = freeCharsText(b, after, before);
|
|
183
240
|
const parts = [];
|
|
184
241
|
if (balance != null) parts.push(`inneign ${formatIsk(balance)}`);
|
|
185
|
-
if (
|
|
186
|
-
else if (left != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} einingar`);
|
|
187
|
-
else if (daily != null && daily > 0) parts.push(`ókeypis ${formatInt(daily)} einingar á dag`);
|
|
242
|
+
if (free) parts.push(free);
|
|
188
243
|
if (parts.length) lines.push(`- Staða: ${parts.join(" · ")}`);
|
|
189
244
|
else lines.push("- Staða: náðist ekki að sækja (`npx altalandi whoami`)");
|
|
190
245
|
if (balance != null && balance < 50) lines.push("- Bæta við inneign: https://altalandi.is/stjornbord/inneign");
|
|
@@ -329,6 +384,8 @@ export function batchReportMarkdown(summary) {
|
|
|
329
384
|
`| Kaflar | ${count(t.passages)}: ${count(t.changed)} breyttir, ${count(t.unchangedPassages)} þurftu ekki breytingu, `
|
|
330
385
|
+ `${count(t.kept)} héldu frumtextanum, ${count(t.failed)} mistókust |`,
|
|
331
386
|
`| Stafir sendir | ${count(t.characters)} |`,
|
|
387
|
+
...(Number.isFinite(t.billedCharacters)
|
|
388
|
+
? [`| Stafir rukkaðir | ${t.billedCharactersApprox ? "um " : ""}${count(t.billedCharacters)} |`] : []),
|
|
332
389
|
`| Tegund texta | ${cell(summary?.domain || "general")} |`,
|
|
333
390
|
`| Málsnið | ${cell(summary?.register || "neutral")} |`,
|
|
334
391
|
];
|
package/skill/SKILL.md
CHANGED
|
@@ -14,7 +14,7 @@ description: >-
|
|
|
14
14
|
|
|
15
15
|
Altalandi rewrites English or stiff Icelandic so it reads as if a native wrote
|
|
16
16
|
it, checks that the meaning held, and lists every change with its reason. Each
|
|
17
|
-
call is paid from the user's free daily
|
|
17
|
+
call is paid from the user's free daily characters or their credit.
|
|
18
18
|
|
|
19
19
|
**Follow this procedure exactly, every time.** The user must always see the same
|
|
20
20
|
thing: the verdict, the text before and after, each change with its reason, what
|
|
@@ -52,7 +52,7 @@ a check is rewritten once more, which can double that.
|
|
|
52
52
|
3. **Paste its stdout into your reply exactly as printed**, as the first thing in
|
|
53
53
|
your reply. It is Markdown: a verdict line, **Fyrir**, **Eftir**,
|
|
54
54
|
**Breytingar** (each change and its reason), **Kostnaður** (cost of this call,
|
|
55
|
-
balance and free
|
|
55
|
+
balance and free characters left) and **Verkið**, the run's own page. Do not
|
|
56
56
|
shorten, reorder, translate or reword it. The run page shows the user the
|
|
57
57
|
text sent, the result and the checks, and lets them approve, reject or edit
|
|
58
58
|
the result there; point them to it when they want to review the work.
|
|
@@ -70,7 +70,7 @@ a check is rewritten once more, which can double that.
|
|
|
70
70
|
of your own.
|
|
71
71
|
|
|
72
72
|
A document of several sections (more than about 3,000 characters) goes better
|
|
73
|
-
through the folder run below: it
|
|
73
|
+
through the folder run below: it refines and checks each passage on its own,
|
|
74
74
|
and a passage that fails keeps its original without holding up the rest. Save
|
|
75
75
|
the text as a `.md` file in an empty temporary folder and run the folder steps.
|
|
76
76
|
|
|
@@ -120,10 +120,15 @@ charged at https://altalandi.is/stjornbord/notkun. With the tools:
|
|
|
120
120
|
- **Never hand-edit the Icelandic and present it as Altalandi's.** If you think
|
|
121
121
|
something should still change, say so separately under a heading such as
|
|
122
122
|
"Mín tillaga (ekki yfirfarin af Altalandi)", or ask before running it again.
|
|
123
|
-
- **One call per coherent text** (a page section, an email
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
- **One call per coherent text** (a page section, an email). Keep keys,
|
|
124
|
+
placeholders (`{name}`, `%s`), HTML and code out of the text you send. For a
|
|
125
|
+
whole folder, i18n catalog or website, use the folder run below instead.
|
|
126
|
+
- **Many short strings** (UI strings, an i18n catalog, a list of labels or
|
|
127
|
+
messages): **never call Altalandi once per string.** Put them in a JSON file
|
|
128
|
+
(`{"key": "English text", …}`) in an empty temporary folder, or use the
|
|
129
|
+
project's existing catalog folder, and run the folder run below on that folder
|
|
130
|
+
(`--dry-run` first). It sends short strings together in batches, which is
|
|
131
|
+
cheaper and faster, and still checks each string on its own.
|
|
127
132
|
- **Product names and coined words** the meaning check cannot recognise can make
|
|
128
133
|
it refuse a rewrite (exit 3, "ekki tókst að staðfesta"). That is the safe
|
|
129
134
|
result: keep the original and tell the user.
|
|
@@ -132,11 +137,13 @@ charged at https://altalandi.is/stjornbord/notkun. With the tools:
|
|
|
132
137
|
writes `./altalandi-reports/altalandi-<time>-<id>.md`, and the block shows
|
|
133
138
|
its path. Reports contain the text, so write them only when asked.
|
|
134
139
|
- **Don't use `--fast`** for text people will read: it skips the meaning check.
|
|
140
|
+
- **Cost is in characters and krónur,** as the block and the estimate show it.
|
|
141
|
+
Never work out or quote a price per 100 characters.
|
|
135
142
|
- If the user only wants spelling fixed and every other word kept, use the
|
|
136
143
|
`proofread_icelandic` tool where it is available. The command line rewrites:
|
|
137
144
|
tell the user it may rephrase, and go through the changes with them.
|
|
138
145
|
- Every call from a signed-in account is kept in that account's history for
|
|
139
|
-
|
|
146
|
+
as long as they keep it, so the run page can show it; only the account and its team see it,
|
|
140
147
|
and it can be deleted on the page or switched off in Settings. Separately,
|
|
141
148
|
free use is kept, with personal data scrubbed, to improve the service; paid
|
|
142
149
|
use only with consent. Add `--no-store` when the user asks that their text
|
|
@@ -152,7 +159,7 @@ check passed.
|
|
|
152
159
|
|
|
153
160
|
1. **Estimate first:** `npx -y altalandi@latest <folder or URL> --dry-run` (add
|
|
154
161
|
`--site` for a whole site). Show the user the estimate it prints: files,
|
|
155
|
-
passages, characters
|
|
162
|
+
passages, the characters billed and the price if paid from credit.
|
|
156
163
|
2. **Only when the user agrees,** run the same command with `--yes` in place of
|
|
157
164
|
`--dry-run`, with the shell timeout set to 10 minutes or in the background.
|
|
158
165
|
The result goes to `<folder>-is` unless they name a place (`--out <path>`).
|
|
@@ -169,12 +176,12 @@ Only when a call exits with 2 (`Not logged in`):
|
|
|
169
176
|
minutes.
|
|
170
177
|
2. As soon as it prints a link (`https://altalandi.is/tengja?kodi=XXXX-XXXX`) and
|
|
171
178
|
a code, show both to the user. Tell them to sign in or create an account
|
|
172
|
-
(
|
|
179
|
+
(a free daily allowance of characters, and 500 kr. of trial credit once they confirm their email) and
|
|
173
180
|
click **Tengja**.
|
|
174
181
|
3. When it prints `✓ Tengt sem <username>`, run step 2 of the procedure again.
|
|
175
182
|
|
|
176
183
|
Never ask the user for an API key, and do not tell them to set
|
|
177
184
|
`ALTALANDI_API_KEY`. `npx -y altalandi@latest whoami` shows the account, the
|
|
178
|
-
balance and the free
|
|
185
|
+
balance and the free characters left today at any time.
|
|
179
186
|
|
|
180
187
|
Docs for building the API into an app: https://altalandi.is/agents.md
|
package/update.mjs
CHANGED
|
@@ -140,6 +140,8 @@ export const SHIPPED_SKILLS = new Set([
|
|
|
140
140
|
"f7de208557f519a4", // 0.4.0
|
|
141
141
|
"4bd64565d251641e", // 0.5.0
|
|
142
142
|
"aeb88010599539fd", // 0.6.0
|
|
143
|
+
"ebfc4ef17a614973", // 0.6.1
|
|
144
|
+
"9def55ecc6b30785", // 0.6.2
|
|
143
145
|
]);
|
|
144
146
|
|
|
145
147
|
export function skillFingerprint(text) {
|