altalandi 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/altalandi.mjs +32 -0
- package/batch.mjs +143 -20
- package/client.d.ts +238 -179
- package/client.mjs +288 -258
- package/package.json +1 -1
- package/report.mjs +20 -1
- package/skill/SKILL.md +17 -8
- package/update.mjs +1 -0
package/client.mjs
CHANGED
|
@@ -1,258 +1,288 @@
|
|
|
1
|
-
const DEFAULT_BASE_URL = "https://altalandi.is/api";
|
|
2
|
-
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
3
|
-
const REQUEST_FIELDS = [
|
|
4
|
-
"sourceLang",
|
|
5
|
-
"domain",
|
|
6
|
-
"register",
|
|
7
|
-
"preserveMeaning",
|
|
8
|
-
"scoreNativeness",
|
|
9
|
-
"onlyImprove",
|
|
10
|
-
"polishPass",
|
|
11
|
-
"writer",
|
|
12
|
-
"scoring",
|
|
13
|
-
"glossary",
|
|
14
|
-
"returnDiffs",
|
|
15
|
-
"contribute",
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
*
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
1
|
+
const DEFAULT_BASE_URL = "https://altalandi.is/api";
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
3
|
+
const REQUEST_FIELDS = [
|
|
4
|
+
"sourceLang",
|
|
5
|
+
"domain",
|
|
6
|
+
"register",
|
|
7
|
+
"preserveMeaning",
|
|
8
|
+
"scoreNativeness",
|
|
9
|
+
"onlyImprove",
|
|
10
|
+
"polishPass",
|
|
11
|
+
"writer",
|
|
12
|
+
"scoring",
|
|
13
|
+
"glossary",
|
|
14
|
+
"returnDiffs",
|
|
15
|
+
"contribute",
|
|
16
|
+
"job",
|
|
17
|
+
];
|
|
18
|
+
// /v1/refine/batch takes fewer options: no writer/scoring tiers, polish or diffs switch.
|
|
19
|
+
const BATCH_REQUEST_FIELDS = [
|
|
20
|
+
"sourceLang",
|
|
21
|
+
"domain",
|
|
22
|
+
"register",
|
|
23
|
+
"preserveMeaning",
|
|
24
|
+
"scoreNativeness",
|
|
25
|
+
"onlyImprove",
|
|
26
|
+
"glossary",
|
|
27
|
+
"autoTerminology",
|
|
28
|
+
"contribute",
|
|
29
|
+
"job",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export class AltalandiError extends Error {
|
|
33
|
+
constructor(message, { code = "ALTALANDI_ERROR", status, retryAfterMs } = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "AltalandiError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
if (Number.isInteger(status)) this.status = status;
|
|
38
|
+
if (Number.isFinite(retryAfterMs)) this.retryAfterMs = retryAfterMs;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Retry-After as milliseconds: delay-seconds or an HTTP date. */
|
|
43
|
+
function retryAfterMs(response) {
|
|
44
|
+
const value = response?.headers?.get?.("retry-after");
|
|
45
|
+
if (!value) return undefined;
|
|
46
|
+
if (/^\s*\d+\s*$/.test(value)) return Number(value) * 1000;
|
|
47
|
+
const at = Date.parse(value);
|
|
48
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class MeaningVerificationError extends AltalandiError {
|
|
52
|
+
constructor(verdict) {
|
|
53
|
+
const changed = verdict === false;
|
|
54
|
+
super(
|
|
55
|
+
changed
|
|
56
|
+
? "Altalandi could not preserve the input meaning."
|
|
57
|
+
: "Altalandi did not return a verified meaning verdict.",
|
|
58
|
+
{ code: changed ? "MEANING_NOT_PRESERVED" : "MEANING_UNVERIFIED" },
|
|
59
|
+
);
|
|
60
|
+
this.name = "MeaningVerificationError";
|
|
61
|
+
this.verdict = verdict === false ? false : null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function env(name) {
|
|
66
|
+
if (typeof process === "undefined" || !process.env) return undefined;
|
|
67
|
+
return process.env[name];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cleanText(value, secret = "") {
|
|
71
|
+
if (typeof value !== "string") return "";
|
|
72
|
+
let cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
73
|
+
if (secret) cleaned = cleaned.split(secret).join("[redacted]");
|
|
74
|
+
return cleaned.slice(0, 300);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function errorDetail(payload) {
|
|
78
|
+
const detail = payload?.detail;
|
|
79
|
+
if (typeof detail === "string") return detail;
|
|
80
|
+
if (Array.isArray(detail)) {
|
|
81
|
+
return detail
|
|
82
|
+
.map((entry) => (typeof entry?.msg === "string" ? entry.msg : ""))
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("; ");
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requestBody(text, options) {
|
|
90
|
+
const body = { text };
|
|
91
|
+
for (const field of REQUEST_FIELDS) {
|
|
92
|
+
if (options[field] !== undefined) body[field] = options[field];
|
|
93
|
+
}
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** POST a JSON body to `${baseUrl}${route}` with the key, base URL, timeout and
|
|
98
|
+
* error handling every call shares. Returns the payload when `validShape`
|
|
99
|
+
* accepts it; throws an AltalandiError (with status and Retry-After) otherwise. */
|
|
100
|
+
async function post(route, body, options, validShape) {
|
|
101
|
+
const explicitKey = options.apiKey;
|
|
102
|
+
const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
|
|
103
|
+
if (selectedKey !== undefined && typeof selectedKey !== "string") {
|
|
104
|
+
throw new AltalandiError("Altalandi apiKey must be a string.", { code: "INVALID_CONFIG" });
|
|
105
|
+
}
|
|
106
|
+
const apiKey = selectedKey?.trim();
|
|
107
|
+
if (!apiKey) {
|
|
108
|
+
throw new AltalandiError(
|
|
109
|
+
"No Altalandi API key. Set ALTALANDI_API_KEY or pass apiKey explicitly.",
|
|
110
|
+
{ code: "MISSING_API_KEY" },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const selectedBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
115
|
+
if (typeof selectedBaseUrl !== "string") {
|
|
116
|
+
throw new AltalandiError("Altalandi baseUrl must be a string.", { code: "INVALID_CONFIG" });
|
|
117
|
+
}
|
|
118
|
+
const baseUrl = selectedBaseUrl.trim().replace(/\/+$/, "");
|
|
119
|
+
if (!baseUrl) {
|
|
120
|
+
throw new AltalandiError("Altalandi baseUrl must not be empty.", { code: "INVALID_CONFIG" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
124
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
125
|
+
throw new AltalandiError("Altalandi timeoutMs must be a positive number.", {
|
|
126
|
+
code: "INVALID_CONFIG",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
131
|
+
if (typeof fetchImpl !== "function") {
|
|
132
|
+
throw new AltalandiError("This runtime does not provide fetch.", { code: "INVALID_CONFIG" });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
137
|
+
try {
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await fetchImpl(`${baseUrl}${route}`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"content-type": "application/json",
|
|
144
|
+
authorization: `Bearer ${apiKey}`,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify(body),
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
if (controller.signal.aborted) {
|
|
151
|
+
throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
|
|
152
|
+
code: "TIMEOUT",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
throw new AltalandiError("Altalandi could not reach the API.", { code: "NETWORK_ERROR" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let raw = "";
|
|
159
|
+
try {
|
|
160
|
+
raw = await response.text();
|
|
161
|
+
} catch {
|
|
162
|
+
if (controller.signal.aborted) {
|
|
163
|
+
throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
|
|
164
|
+
code: "TIMEOUT",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (response.ok) {
|
|
168
|
+
throw new AltalandiError("Altalandi could not read the API response.", {
|
|
169
|
+
code: "INVALID_RESPONSE",
|
|
170
|
+
status: response.status,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// The HTTP status remains useful when an error response has no readable body.
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let payload;
|
|
177
|
+
if (raw) {
|
|
178
|
+
try {
|
|
179
|
+
payload = JSON.parse(raw);
|
|
180
|
+
} catch {
|
|
181
|
+
if (response.ok) {
|
|
182
|
+
throw new AltalandiError("Altalandi returned an invalid JSON response.", {
|
|
183
|
+
code: "INVALID_RESPONSE",
|
|
184
|
+
status: response.status,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const status = Number.isInteger(response.status) ? response.status : 0;
|
|
192
|
+
const statusText = cleanText(response.statusText);
|
|
193
|
+
const detail = cleanText(errorDetail(payload), apiKey);
|
|
194
|
+
const suffix = detail || statusText;
|
|
195
|
+
throw new AltalandiError(
|
|
196
|
+
`Altalandi API request failed (${status || "unknown status"})${suffix ? `: ${suffix}` : "."}`,
|
|
197
|
+
{ code: "API_ERROR", status, retryAfterMs: retryAfterMs(response) },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload) || !validShape(payload)) {
|
|
202
|
+
throw new AltalandiError("Altalandi returned an invalid response shape.", {
|
|
203
|
+
code: "INVALID_RESPONSE",
|
|
204
|
+
status: response.status,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return payload;
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Refine English or Icelandic text through the hosted Altalandi API.
|
|
216
|
+
* Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
|
|
217
|
+
* the JSON body. The full API result is returned without weakening its verdict.
|
|
218
|
+
*/
|
|
219
|
+
export async function refine(text, options = {}) {
|
|
220
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
221
|
+
throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
|
|
222
|
+
}
|
|
223
|
+
return post("/v1/refine", requestBody(text, options), options, (p) => typeof p.output === "string");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Refine many short strings in one call (POST /v1/refine/batch): at most 500
|
|
228
|
+
* items of at most 2,000 characters each, 20,000 characters together. Each item
|
|
229
|
+
* comes back with its own output and meaning verdict; an item with `error` came
|
|
230
|
+
* back unchanged and was not billed. Same key, limits and errors as refine().
|
|
231
|
+
*/
|
|
232
|
+
export async function refineBatch(items, options = {}) {
|
|
233
|
+
if (!Array.isArray(items) || !items.length
|
|
234
|
+
|| !items.every((it) => it && typeof it.text === "string" && (it.id === undefined || typeof it.id === "string")
|
|
235
|
+
&& (it.part === undefined || typeof it.part === "string"))) {
|
|
236
|
+
throw new AltalandiError("Altalandi refineBatch requires a non-empty list of { id?, text, part? } items.", {
|
|
237
|
+
code: "INVALID_INPUT",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const body = {
|
|
241
|
+
items: items.map((it) => ({
|
|
242
|
+
...(it.id === undefined ? {} : { id: it.id }),
|
|
243
|
+
text: it.text,
|
|
244
|
+
...(it.part === undefined ? {} : { part: it.part }),
|
|
245
|
+
})),
|
|
246
|
+
};
|
|
247
|
+
for (const field of BATCH_REQUEST_FIELDS) {
|
|
248
|
+
if (options[field] !== undefined) body[field] = options[field];
|
|
249
|
+
}
|
|
250
|
+
return post("/v1/refine/batch", body, options, (p) => Array.isArray(p.items)
|
|
251
|
+
&& p.items.every((it) => it && typeof it === "object" && typeof it.output === "string"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Create or name a job ("verk") and have the engine classify it from a sample
|
|
256
|
+
* (POST /v1/jobs): the kind of text and its specialist fields. Free, not
|
|
257
|
+
* billed. `sample` is a string or a list of strings, at most 12,000 characters
|
|
258
|
+
* together (about 3,000 is plenty). An existing job keeps its classification
|
|
259
|
+
* unless `reclassify` is true. An engine without jobs answers 404.
|
|
260
|
+
*/
|
|
261
|
+
export async function createJob(job, options = {}) {
|
|
262
|
+
if (!job || typeof job.id !== "string" || !job.id
|
|
263
|
+
|| !(typeof job.sample === "string" || (Array.isArray(job.sample) && job.sample.every((s) => typeof s === "string")))) {
|
|
264
|
+
throw new AltalandiError("Altalandi createJob requires { id, sample } (sample: a string or a list of strings).", {
|
|
265
|
+
code: "INVALID_INPUT",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const body = { id: job.id, sample: job.sample };
|
|
269
|
+
for (const field of ["title", "sourceLang", "reclassify"]) {
|
|
270
|
+
if (job[field] !== undefined) body[field] = job[field];
|
|
271
|
+
}
|
|
272
|
+
return post("/v1/jobs", body, options, (p) => typeof p.id === "string");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Assert that an existing result carries a completed, positive meaning check. */
|
|
276
|
+
export function assertMeaningVerified(result) {
|
|
277
|
+
if (result?.meaningPreserved !== true) {
|
|
278
|
+
throw new MeaningVerificationError(result?.meaningPreserved);
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Request meaning verification and reject unless the API returns strict true. */
|
|
284
|
+
export async function refineVerified(text, options = {}) {
|
|
285
|
+
const result = await refine(text, { ...options, preserveMeaning: true });
|
|
286
|
+
assertMeaningVerified(result);
|
|
287
|
+
return result;
|
|
288
|
+
}
|
package/package.json
CHANGED
package/report.mjs
CHANGED
|
@@ -89,6 +89,7 @@ export function reportMarkdown(input, data, options = {}) {
|
|
|
89
89
|
`| Innfæddni | ${q.nativenessScore ?? "n/a"}/100 |`,
|
|
90
90
|
`| Ritill | ${tier.writer || "standard"} / ${tier.scoring || "?"} |`,
|
|
91
91
|
`| Auðkenni | ${data?.runUrl ? `[${data.id}](${data.runUrl})` : data?.id || "?"} |`,
|
|
92
|
+
...(data?.jobUrl ? [`| Verkið | ${data.jobUrl} |`] : []),
|
|
92
93
|
"",
|
|
93
94
|
"## Fyrir", "", fence(input), "",
|
|
94
95
|
"## Eftir", "", fence(data?.output), "",
|
|
@@ -335,7 +336,9 @@ export function reviewMarkdown(input, data, { before = null, after = null, maxCh
|
|
|
335
336
|
|
|
336
337
|
out.push("**Kostnaður**", ...costLines(data, before, after));
|
|
337
338
|
// The run's own page: the text sent, the result, the checks and the review.
|
|
338
|
-
|
|
339
|
+
// A call sent as part of a job (--job) links the job first, then this part.
|
|
340
|
+
if (data?.jobUrl) out.push(`- Verkið: ${data.jobUrl}`);
|
|
341
|
+
if (data?.runUrl) out.push(`- ${data?.jobUrl ? "Hlutinn" : "Verkið"}: ${data.runUrl}`);
|
|
339
342
|
else if (data?.id) out.push(`- Auðkenni: \`${data.id}\``);
|
|
340
343
|
return out.join("\n") + "\n";
|
|
341
344
|
}
|
|
@@ -362,6 +365,18 @@ const count = (n) => formatInt(n || 0);
|
|
|
362
365
|
/** The one summary of a folder or site run: every file, every changed passage
|
|
363
366
|
* (before and after, with the meaning verdict) and, under their own heading,
|
|
364
367
|
* the passages that keep the original text because a check did not pass. */
|
|
368
|
+
/** A job's classification (POST /v1/jobs) as one line: "Vefsíða · Bókhald",
|
|
369
|
+
* the kind and the field names. null when it is not known: uncertain, a
|
|
370
|
+
* fallback, or nothing named. */
|
|
371
|
+
export function jobTypeText(classification) {
|
|
372
|
+
const c = classification;
|
|
373
|
+
if (!c || typeof c !== "object" || c.uncertain || c.source === "fallback") return null;
|
|
374
|
+
const name = (x) => String(x?.name || x?.label || x?.code || "").replace(/\s+/g, " ").trim();
|
|
375
|
+
const kind = c.kind ? name(c.kind) : "";
|
|
376
|
+
const fields = (Array.isArray(c.fields) ? c.fields : []).map(name).filter(Boolean).join(", ");
|
|
377
|
+
return [kind, fields].filter(Boolean).join(" · ") || null;
|
|
378
|
+
}
|
|
379
|
+
|
|
365
380
|
export function batchReportMarkdown(summary) {
|
|
366
381
|
const t = summary?.totals || {};
|
|
367
382
|
const files = Array.isArray(summary?.files) ? summary.files : [];
|
|
@@ -386,8 +401,12 @@ export function batchReportMarkdown(summary) {
|
|
|
386
401
|
`| Stafir sendir | ${count(t.characters)} |`,
|
|
387
402
|
...(Number.isFinite(t.billedCharacters)
|
|
388
403
|
? [`| Stafir rukkaðir | ${t.billedCharactersApprox ? "um " : ""}${count(t.billedCharacters)} |`] : []),
|
|
404
|
+
// The job's kind and fields, as the engine classified them (when it knows).
|
|
405
|
+
...(jobTypeText(summary?.classification) ? [`| Tegund verks | ${cell(jobTypeText(summary.classification))} |`] : []),
|
|
389
406
|
`| Tegund texta | ${cell(summary?.domain || "general")} |`,
|
|
390
407
|
`| Málsnið | ${cell(summary?.register || "neutral")} |`,
|
|
408
|
+
// The run's one job page (an engine that knows jobs); never one link per call.
|
|
409
|
+
...(summary?.jobUrl ? [`| Verkið | ${summary.jobUrl} |`] : []),
|
|
391
410
|
];
|
|
392
411
|
|
|
393
412
|
if (summary?.stopped) {
|