@kontourai/survey 2.2.4 → 2.4.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/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +1 -0
- package/dist/src/review-resource.d.ts +13 -0
- package/dist/src/review-resource.js +21 -0
- package/dist/src/review-workbench/audit-rows.d.ts +72 -0
- package/dist/src/review-workbench/audit-rows.js +133 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +67 -2
- package/dist/src/review-workbench/extraction-inspector.js +233 -22
- package/dist/src/review-workbench/queue-binding.d.ts +127 -0
- package/dist/src/review-workbench/queue-binding.js +324 -0
- package/dist/src/review-workbench/review-presentation.js +28 -1
- package/dist/src/review-workbench/review-queue-session.d.ts +11 -0
- package/dist/src/review-workbench/review-queue-session.js +13 -1
- package/dist/src/review-workbench/review-surface-preview.js +2 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +45 -11
- package/dist/src/review-workbench/review-workbench-element.js +24 -1
- package/dist/src/review-workbench/review-workbench.css +45 -11
- package/dist/src/review-workbench/review-workbench.d.ts +3 -1
- package/dist/src/review-workbench/review-workbench.js +173 -60
- package/dist/src/review-workbench/review-workbench.standalone.css +45 -11
- package/dist/src/review-workbench/server-review-session.d.ts +14 -0
- package/dist/src/review-workbench/server-review-session.js +7 -0
- package/package.json +2 -1
|
@@ -41,7 +41,77 @@ export function buildExtractionInspectorModel(input) {
|
|
|
41
41
|
candidates[index].alignment = "excerpt-mismatch";
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
return { sources, candidates };
|
|
44
|
+
return { sources, candidates: bindHighlightElementIds(candidates) };
|
|
45
|
+
}
|
|
46
|
+
const HIGHLIGHT_ID_PREFIX = "highlight-";
|
|
47
|
+
/**
|
|
48
|
+
* Assigns every candidate the element id its source highlight will carry.
|
|
49
|
+
*
|
|
50
|
+
* Done here, over the whole model, rather than by a pure per-id derivation:
|
|
51
|
+
* sanitizing a candidate id to an HTML identifier is lossy (`a:b.c` and `a:b-c`
|
|
52
|
+
* both reduce to `a-b-c`), and two anchors sharing an id silently resolve a
|
|
53
|
+
* host's link to the wrong sentence. Today's candidate-id scheme happens not to
|
|
54
|
+
* produce that collision — the entry index always separates two sources — so
|
|
55
|
+
* the disambiguating suffix is defensive rather than a fix for a reachable bug.
|
|
56
|
+
* It is here because uniqueness is now a published promise
|
|
57
|
+
* ({@link ExtractionInspectorCandidate.highlightElementId}) that must survive a
|
|
58
|
+
* later change to the key scheme rather than depend on one.
|
|
59
|
+
*/
|
|
60
|
+
function bindHighlightElementIds(candidates) {
|
|
61
|
+
const used = new Set();
|
|
62
|
+
return candidates.map((candidate) => ({
|
|
63
|
+
...candidate,
|
|
64
|
+
highlightElementId: uniqueHighlightElementId(candidate.id, used),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Derives an element id not already taken.
|
|
69
|
+
*
|
|
70
|
+
* `used` holds finished element ids — prefix included — and so does the value
|
|
71
|
+
* returned, because the two have to be the same form to be comparable. They were
|
|
72
|
+
* not: uniqueness was checked on the bare token while the set held prefixed ids,
|
|
73
|
+
* so a published `highlight-alpha` did not stop a candidate with id `alpha` from
|
|
74
|
+
* deriving `highlight-alpha` and mounting a second element under it. A host's
|
|
75
|
+
* `href` then resolved to whichever came first.
|
|
76
|
+
*/
|
|
77
|
+
function uniqueHighlightElementId(candidateId, used) {
|
|
78
|
+
const base = `${HIGHLIGHT_ID_PREFIX}${safeId(candidateId)}`;
|
|
79
|
+
let elementId = base;
|
|
80
|
+
for (let suffix = 2; used.has(elementId); suffix += 1)
|
|
81
|
+
elementId = `${base}-${suffix}`;
|
|
82
|
+
used.add(elementId);
|
|
83
|
+
return elementId;
|
|
84
|
+
}
|
|
85
|
+
/** The inspector's own candidate-list id, paired 1:1 with a highlight anchor. Not public. */
|
|
86
|
+
function candidateElementId(highlightElementId) {
|
|
87
|
+
return `candidate-${highlightElementId.slice(HIGHLIGHT_ID_PREFIX.length)}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The element id each candidate's anchor will carry, for one mount.
|
|
91
|
+
*
|
|
92
|
+
* A model from {@link buildExtractionInspectorModel} already carries one per
|
|
93
|
+
* candidate and it is used verbatim — rewriting a published id is precisely the
|
|
94
|
+
* drift this contract exists to prevent. A hand-authored model may omit some or
|
|
95
|
+
* all of them; those get an id derived here, seeded with the published ones so a
|
|
96
|
+
* derived id can never collide with one a host is already linking to.
|
|
97
|
+
*
|
|
98
|
+
* Keyed on the candidate OBJECT, not on `candidate.id`. The builder's ids are
|
|
99
|
+
* structurally unique, but this function exists for models a caller assembled,
|
|
100
|
+
* where nothing enforces that — and an id-keyed map silently collapsed two such
|
|
101
|
+
* candidates into one entry, handing the second's derived id to the first and
|
|
102
|
+
* discarding the very `highlightElementId` a host was already linking to. Object
|
|
103
|
+
* identity cannot collide, whatever the data says.
|
|
104
|
+
*/
|
|
105
|
+
function resolveHighlightElementIds(model) {
|
|
106
|
+
const used = new Set();
|
|
107
|
+
for (const candidate of model.candidates)
|
|
108
|
+
if (candidate.highlightElementId)
|
|
109
|
+
used.add(candidate.highlightElementId);
|
|
110
|
+
const resolved = new Map();
|
|
111
|
+
for (const candidate of model.candidates) {
|
|
112
|
+
resolved.set(candidate, candidate.highlightElementId ?? uniqueHighlightElementId(candidate.id, used));
|
|
113
|
+
}
|
|
114
|
+
return resolved;
|
|
45
115
|
}
|
|
46
116
|
function assertResolvedArtifact(artifact) {
|
|
47
117
|
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
|
|
@@ -164,7 +234,9 @@ export function exportExtractionInspector(model, options = {}) {
|
|
|
164
234
|
return canonicalJson({ apiVersion: "survey.kontourai.io/v1alpha1", kind: "ExtractionInspectorExport", spec: {
|
|
165
235
|
redaction: { preparedTextIncluded: options.includePreparedText === true, excerptsIncluded: options.includeExcerpts === true },
|
|
166
236
|
sources: model.sources.map(({ artifactText, message: _message, ...source }) => ({ ...source, preparedText: options.includePreparedText ? artifactText ?? null : "[redacted]" })),
|
|
167
|
-
|
|
237
|
+
// highlightElementId is a DOM binding for a live inspector, not extraction
|
|
238
|
+
// evidence — it stays out of the canonical export (and out of its digest).
|
|
239
|
+
candidates: model.candidates.map(({ highlightElementId: _highlightElementId, ...candidate }) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
|
|
168
240
|
} });
|
|
169
241
|
}
|
|
170
242
|
export function mountExtractionInspector(container, model, options = {}) {
|
|
@@ -172,6 +244,8 @@ export function mountExtractionInspector(container, model, options = {}) {
|
|
|
172
244
|
? Math.min(Number(options.pageSize), 500)
|
|
173
245
|
: 100;
|
|
174
246
|
let page = 0;
|
|
247
|
+
const highlightIds = resolveHighlightElementIds(model);
|
|
248
|
+
const highlightIdFor = (candidate) => highlightIds.get(candidate);
|
|
175
249
|
const root = document.createElement("section");
|
|
176
250
|
root.className = "extraction-inspector";
|
|
177
251
|
root.setAttribute("aria-label", "Source-linked extraction inspector");
|
|
@@ -188,7 +262,7 @@ export function mountExtractionInspector(container, model, options = {}) {
|
|
|
188
262
|
page = Math.min(page, pageCount - 1);
|
|
189
263
|
const start = page * pageSize;
|
|
190
264
|
const visible = matching.slice(start, start + pageSize);
|
|
191
|
-
list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="
|
|
265
|
+
list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="${escapeHtml(candidateElementId(highlightIdFor(c)))}" data-candidate-id="${escapeHtml(c.id)}" data-highlight-element-id="${escapeHtml(highlightIdFor(c))}" aria-controls="${escapeHtml(highlightIdFor(c))}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
|
|
192
266
|
resultCount.textContent = matching.length === 0 ? "No matching candidates" : `${start + 1}–${Math.min(start + pageSize, matching.length)} of ${matching.length}`;
|
|
193
267
|
pageLabel.textContent = `Page ${page + 1} of ${pageCount}`;
|
|
194
268
|
pageLabel.hidden = pageCount === 1;
|
|
@@ -197,7 +271,7 @@ export function mountExtractionInspector(container, model, options = {}) {
|
|
|
197
271
|
previous.disabled = page === 0;
|
|
198
272
|
next.disabled = page >= pageCount - 1;
|
|
199
273
|
postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
|
|
200
|
-
sourcesRoot.innerHTML = model.sources.map(s => { const candidates = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ?
|
|
274
|
+
sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
|
|
201
275
|
};
|
|
202
276
|
root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
|
|
203
277
|
filters[key] = select.value;
|
|
@@ -209,50 +283,187 @@ export function mountExtractionInspector(container, model, options = {}) {
|
|
|
209
283
|
delete filters.query; page = 0; render(); });
|
|
210
284
|
previous.addEventListener("click", () => { page = Math.max(0, page - 1); render(); list.querySelector("button")?.focus(); });
|
|
211
285
|
next.addEventListener("click", () => { page += 1; render(); list.querySelector("button")?.focus(); });
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
286
|
+
// The event carries the resolved binding as well as the identity: a listener
|
|
287
|
+
// that wants to focus the highlight must never reconstruct the id, and the
|
|
288
|
+
// first-party custom element is the reference implementation of that rule.
|
|
289
|
+
const candidateFor = (highlightElementId) => model.candidates.find(c => highlightIdFor(c) === highlightElementId);
|
|
290
|
+
const activateCandidate = (highlightElementId) => { const candidate = candidateFor(highlightElementId); if (!candidate)
|
|
291
|
+
return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: candidate.id, reviewItemName: candidate.reviewItemName, highlightElementId } })); };
|
|
292
|
+
const clearFilters = () => {
|
|
293
|
+
for (const key of Object.keys(filters))
|
|
294
|
+
delete filters[key];
|
|
295
|
+
root.querySelectorAll("select[data-filter]").forEach(select => { select.value = ""; });
|
|
296
|
+
const search = root.querySelector('input[data-filter="query"]');
|
|
297
|
+
if (search)
|
|
298
|
+
search.value = "";
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Brings a candidate's row onto the mounted page so it can be focused.
|
|
302
|
+
*
|
|
303
|
+
* Anchors exist for every candidate, so a highlight can be activated while its
|
|
304
|
+
* candidate row is on another page or filtered out entirely. Returning focus
|
|
305
|
+
* to a row that is not mounted would silently do nothing, so page to it —
|
|
306
|
+
* clearing the filters first if they are what is hiding it.
|
|
307
|
+
*/
|
|
308
|
+
const revealCandidateRow = (highlightElementId) => {
|
|
309
|
+
const candidate = candidateFor(highlightElementId);
|
|
310
|
+
if (!candidate)
|
|
311
|
+
return undefined;
|
|
312
|
+
let index = filterExtractionInspectorCandidates(model, filters).indexOf(candidate);
|
|
313
|
+
if (index < 0) {
|
|
314
|
+
clearFilters();
|
|
315
|
+
index = model.candidates.indexOf(candidate);
|
|
316
|
+
}
|
|
317
|
+
if (index < 0)
|
|
318
|
+
return undefined;
|
|
319
|
+
page = Math.floor(index / pageSize);
|
|
320
|
+
render();
|
|
321
|
+
return candidate;
|
|
322
|
+
};
|
|
323
|
+
const returnToCandidate = (highlightElementId) => {
|
|
324
|
+
if (revealCandidateRow(highlightElementId))
|
|
325
|
+
root.querySelector(`#${CSS.escape(candidateElementId(highlightElementId))}`)?.focus();
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Follows a host's `href="#<highlightElementId>"`.
|
|
329
|
+
*
|
|
330
|
+
* The anchor that id names always exists, but a candidate off the current page
|
|
331
|
+
* or excluded by a filter has no painted highlight, so the link would land the
|
|
332
|
+
* reader on an invisible marker in the middle of the source text. Page to the
|
|
333
|
+
* candidate instead, so the highlight it points at is actually painted, and put
|
|
334
|
+
* focus on it.
|
|
335
|
+
*/
|
|
336
|
+
const followHighlightFragment = () => {
|
|
337
|
+
const fragment = typeof location === "undefined" ? "" : location.hash.slice(1);
|
|
338
|
+
if (!fragment)
|
|
339
|
+
return;
|
|
340
|
+
if (!candidateFor(fragment))
|
|
341
|
+
return;
|
|
342
|
+
revealCandidateRow(fragment);
|
|
343
|
+
// `~=` matches one whitespace-separated token: a mark painted over a span
|
|
344
|
+
// shared by several candidates lists all of them, and this is the one the
|
|
345
|
+
// reader asked for. Record it, so activating that mark returns to the
|
|
346
|
+
// candidate they arrived by rather than to whichever is listed first.
|
|
347
|
+
const mark = root.querySelector(`[data-highlight-return-to~="${CSS.escape(fragment)}"]`);
|
|
348
|
+
if (!mark)
|
|
349
|
+
return;
|
|
350
|
+
mark.dataset.highlightArrivedBy = fragment;
|
|
351
|
+
mark.focus();
|
|
352
|
+
mark.scrollIntoView({ block: "center" });
|
|
353
|
+
};
|
|
354
|
+
const onHashChange = () => followHighlightFragment();
|
|
355
|
+
if (typeof window !== "undefined")
|
|
356
|
+
window.addEventListener("hashchange", onHashChange);
|
|
357
|
+
/** The candidate a shared mark returns to: the one the reader arrived by, else the first it covers. */
|
|
358
|
+
const returnTargetOf = (mark) => mark.dataset.highlightArrivedBy ?? mark.dataset.highlightReturnTo.split(" ")[0];
|
|
359
|
+
root.addEventListener("click", event => { const candidate = event.target.closest("button[data-highlight-element-id]"); if (candidate) {
|
|
215
360
|
event.preventDefault();
|
|
216
361
|
event.stopPropagation();
|
|
217
|
-
activateCandidate(candidate.dataset.
|
|
362
|
+
activateCandidate(candidate.dataset.highlightElementId);
|
|
218
363
|
return;
|
|
219
|
-
} const highlight = event.target.closest("
|
|
364
|
+
} const highlight = event.target.closest("[data-highlight-return-to]"); if (highlight) {
|
|
220
365
|
event.preventDefault();
|
|
221
366
|
event.stopPropagation();
|
|
222
|
-
|
|
367
|
+
returnToCandidate(returnTargetOf(highlight));
|
|
223
368
|
} });
|
|
224
|
-
root.addEventListener("keydown", event => { const
|
|
369
|
+
root.addEventListener("keydown", event => { const highlight = event.target.closest("[data-highlight-return-to]"); if (highlight) {
|
|
370
|
+
if (event.key !== "Enter" && event.key !== " ")
|
|
371
|
+
return;
|
|
372
|
+
event.preventDefault();
|
|
373
|
+
event.stopPropagation();
|
|
374
|
+
returnToCandidate(returnTargetOf(highlight));
|
|
375
|
+
return;
|
|
376
|
+
} const button = event.target.closest("button[data-highlight-element-id]"); if (!button)
|
|
225
377
|
return; if (event.key === "Enter" || event.key === " ") {
|
|
226
378
|
event.preventDefault();
|
|
227
379
|
event.stopPropagation();
|
|
228
|
-
activateCandidate(button.dataset.
|
|
380
|
+
activateCandidate(button.dataset.highlightElementId);
|
|
229
381
|
return;
|
|
230
382
|
} if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
231
383
|
event.preventDefault();
|
|
232
|
-
const buttons = [...list.querySelectorAll("button[data-
|
|
384
|
+
const buttons = [...list.querySelectorAll("button[data-highlight-element-id]")];
|
|
233
385
|
const index = buttons.indexOf(button);
|
|
234
386
|
buttons[event.key === "ArrowDown" ? Math.min(index + 1, buttons.length - 1) : Math.max(index - 1, 0)]?.focus();
|
|
235
387
|
} });
|
|
236
388
|
render();
|
|
237
|
-
|
|
389
|
+
followHighlightFragment();
|
|
390
|
+
return () => { if (typeof window !== "undefined")
|
|
391
|
+
window.removeEventListener("hashchange", onHashChange); root.remove(); };
|
|
238
392
|
}
|
|
239
|
-
|
|
393
|
+
/**
|
|
394
|
+
* Renders the prepared text with a return anchor for every candidate and a
|
|
395
|
+
* `<mark>` for the ones on the current page.
|
|
396
|
+
*
|
|
397
|
+
* The two sets are deliberately different. `marked` follows the candidate
|
|
398
|
+
* list's paging and filtering, so the painting tracks what the reviewer is
|
|
399
|
+
* looking at. `anchored` is every candidate the source has, so that the element
|
|
400
|
+
* id published on the model resolves whatever page the reviewer is on and
|
|
401
|
+
* whatever they have typed into the filter box — the anchors carry the contract,
|
|
402
|
+
* the marks carry the view.
|
|
403
|
+
*/
|
|
404
|
+
function renderSource(text, anchored, marked, highlightIdFor) {
|
|
240
405
|
const boundaries = new Set([0, text.length]);
|
|
241
|
-
|
|
242
|
-
|
|
406
|
+
anchored.forEach(c => boundaries.add(c.start));
|
|
407
|
+
marked.forEach(c => { boundaries.add(c.start); boundaries.add(c.end); });
|
|
408
|
+
const points = [...boundaries].filter(point => point >= 0 && point <= text.length).sort((a, b) => a - b);
|
|
243
409
|
let html = "";
|
|
244
410
|
const starts = new Map();
|
|
245
|
-
|
|
411
|
+
anchored.forEach(c => starts.set(c.start, [...(starts.get(c.start) ?? []), c]));
|
|
412
|
+
const emitted = new Set();
|
|
246
413
|
for (let i = 0; i < points.length - 1; i++) {
|
|
247
414
|
const start = points[i], end = points[i + 1];
|
|
248
|
-
for (const c of starts.get(start) ?? [])
|
|
249
|
-
|
|
415
|
+
for (const c of starts.get(start) ?? []) {
|
|
416
|
+
emitted.add(c);
|
|
417
|
+
html += anchorHtml(c, highlightIdFor(c));
|
|
418
|
+
}
|
|
250
419
|
const segment = escapeHtml(text.slice(start, end));
|
|
251
|
-
const active =
|
|
252
|
-
html += active.length ?
|
|
420
|
+
const active = marked.filter(c => c.start < end && c.end > start);
|
|
421
|
+
html += active.length ? markHtml(segment, active, highlightIdFor) : segment;
|
|
253
422
|
}
|
|
423
|
+
// A span starting at or past the end of the prepared text has no segment to
|
|
424
|
+
// lead; its anchor still has to exist, or its published id resolves nowhere.
|
|
425
|
+
for (const c of anchored)
|
|
426
|
+
if (!emitted.has(c))
|
|
427
|
+
html += anchorHtml(c, highlightIdFor(c));
|
|
254
428
|
return html;
|
|
255
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* The link target a host's `href` resolves to: an empty, inert span at the start
|
|
432
|
+
* of the candidate's span.
|
|
433
|
+
*
|
|
434
|
+
* Deliberately NOT a control. It exists for every candidate in the model, which
|
|
435
|
+
* is what makes the published id resolve unconditionally — but a thing that
|
|
436
|
+
* exists per candidate must cost nothing to a keyboard or screen-reader user, and
|
|
437
|
+
* as a `<button>` it did: 600 candidates put 600 invisible tab stops in sequence,
|
|
438
|
+
* several of them stacked together in the non-grounded postures. It was also a
|
|
439
|
+
* one-pixel pointer target once its accidental user-agent chrome was removed —
|
|
440
|
+
* an affordance in name only, at any size, because nothing about it was visible.
|
|
441
|
+
*
|
|
442
|
+
* Returning to the candidate is now the job of the thing a reader can actually
|
|
443
|
+
* see and aim at: the highlight itself. See {@link markHtml}.
|
|
444
|
+
*/
|
|
445
|
+
function anchorHtml(candidate, highlightElementId) {
|
|
446
|
+
return `<span class="highlight-anchor" id="${escapeHtml(highlightElementId)}" data-highlight-candidate-id="${escapeHtml(candidate.id)}"></span>`;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The painted highlight, and the control that returns to its candidate.
|
|
450
|
+
*
|
|
451
|
+
* The highlight is the only part of this surface a reader can see, so it is the
|
|
452
|
+
* target: full phrase width rather than a sliver, discoverable because it is
|
|
453
|
+
* already visibly marked, and one tab stop per painted highlight rather than one
|
|
454
|
+
* per candidate in the model. `data-highlight-return-to` is separate from the
|
|
455
|
+
* anchor's `data-highlight-candidate-id` so the documented reverse lookup keeps
|
|
456
|
+
* resolving to exactly one element.
|
|
457
|
+
*/
|
|
458
|
+
function markHtml(segment, active, highlightIdFor) {
|
|
459
|
+
const fields = active.map(c => escapeHtml(c.field)).join(", ");
|
|
460
|
+
// Every candidate the mark covers, not just the first. Two claims over one
|
|
461
|
+
// span is ordinary in extraction, and naming both in the label while binding
|
|
462
|
+
// only one left the others with a highlight that could not be navigated to or
|
|
463
|
+
// returned from — the label said two, the binding said one.
|
|
464
|
+
const bindings = active.map(c => escapeHtml(highlightIdFor(c))).join(" ");
|
|
465
|
+
return `<mark class="source-highlight" role="button" tabindex="0" data-highlight-return-to="${bindings}" aria-label="Highlighted for ${fields}; activate to return to candidate">${segment}</mark>`;
|
|
466
|
+
}
|
|
256
467
|
function formatContext(candidate) {
|
|
257
468
|
const context = [];
|
|
258
469
|
if (candidate.pdfRegion) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ReviewQueueSessionState } from "./review-queue-session.js";
|
|
2
|
+
import { reviewResourceApiVersion, type ReviewItem } from "../review-resource.js";
|
|
3
|
+
import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
4
|
+
/**
|
|
5
|
+
* The durable attestation record. Serializable; a consumer stores it beside the
|
|
6
|
+
* queue when the round opens and presents it, unchanged, at every later
|
|
7
|
+
* validation.
|
|
8
|
+
*
|
|
9
|
+
* `itemNames` is deliberately redundant with the hash: it is what makes a
|
|
10
|
+
* refusal diagnosable (which item was removed or added, by name) and it keeps
|
|
11
|
+
* the set comparison independent of the hash derivation.
|
|
12
|
+
*/
|
|
13
|
+
export interface ReviewQueueBinding {
|
|
14
|
+
readonly apiVersion: typeof reviewResourceApiVersion;
|
|
15
|
+
readonly kind: "ReviewQueueBinding";
|
|
16
|
+
readonly spec: {
|
|
17
|
+
readonly sessionName: string;
|
|
18
|
+
/** sha256 of the canonical open-time snapshot; see {@link hashReviewQueueSnapshot}. */
|
|
19
|
+
readonly snapshotHash: string;
|
|
20
|
+
/** Sorted, unique names of every ReviewItem the binding covers. Never empty. */
|
|
21
|
+
readonly itemNames: readonly string[];
|
|
22
|
+
/** ISO timestamp of when the binding was taken. Informational, not trusted. */
|
|
23
|
+
readonly boundAt: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export type ReviewQueueBindingIssueCode = "binding-malformed" | "session-name-mismatch" | "empty-queue" | "ambiguous-item-identity" | "snapshot-hash-mismatch" | "item-removed" | "item-added";
|
|
27
|
+
export interface ReviewQueueBindingIssue {
|
|
28
|
+
readonly code: ReviewQueueBindingIssueCode;
|
|
29
|
+
readonly message: string;
|
|
30
|
+
/** The ReviewItem name a set-membership issue is about, when there is one. */
|
|
31
|
+
readonly itemName?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare class UnattestedReviewQueueError extends Error {
|
|
34
|
+
readonly name = "UnattestedReviewQueueError";
|
|
35
|
+
readonly issues: readonly ReviewQueueBindingIssue[];
|
|
36
|
+
constructor(issues: readonly ReviewQueueBindingIssue[]);
|
|
37
|
+
}
|
|
38
|
+
export interface BindReviewQueueOptions {
|
|
39
|
+
readonly sessionName: string;
|
|
40
|
+
/** Defaults to now. Informational only; nothing validates against it. */
|
|
41
|
+
readonly boundAt?: Date | string;
|
|
42
|
+
}
|
|
43
|
+
export interface ValidateReviewQueueBindingOptions {
|
|
44
|
+
/** When set, the binding must name this session. */
|
|
45
|
+
readonly sessionName?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The digest a binding stores: sha256 over the canonical JSON of the whole
|
|
49
|
+
* open-time session state. Byte-identical to server-review-session's
|
|
50
|
+
* `hashReviewSessionSnapshot` (pinned by test), so a consumer already
|
|
51
|
+
* persisting that digest adopts the binding without invalidating stored state.
|
|
52
|
+
*/
|
|
53
|
+
export declare function hashReviewQueueSnapshot(snapshot: ReviewQueueSessionState): string;
|
|
54
|
+
/**
|
|
55
|
+
* Take the binding for a queue, once, when the round/session opens.
|
|
56
|
+
*
|
|
57
|
+
* Call this at queue construction and persist the result beside the queue.
|
|
58
|
+
* Calling it again later, on bytes that may have changed, produces a binding
|
|
59
|
+
* that agrees with whatever it was given — which is the self-agreement bypass,
|
|
60
|
+
* not an attestation.
|
|
61
|
+
*
|
|
62
|
+
* Refuses an empty queue: a binding over nothing validates nothing, and every
|
|
63
|
+
* later check against it would be vacuously green. Refuses duplicate item
|
|
64
|
+
* names: the binding's set comparison is by name, so an ambiguous name would
|
|
65
|
+
* let two different items answer for one membership.
|
|
66
|
+
*/
|
|
67
|
+
export declare function bindReviewQueue(snapshot: ReviewQueueSessionState, options: BindReviewQueueOptions): ReviewQueueBinding;
|
|
68
|
+
/**
|
|
69
|
+
* Compare a presented queue against its stored binding.
|
|
70
|
+
*
|
|
71
|
+
* `binding` must be the record persisted when the round opened — passing one
|
|
72
|
+
* derived from `snapshot` here checks nothing (see module doc). `snapshot` is
|
|
73
|
+
* the base queue the binding was taken over, not the state after event replay:
|
|
74
|
+
* decisions live in the event log precisely so the bound bytes never move.
|
|
75
|
+
*
|
|
76
|
+
* A malformed binding fails closed with `binding-malformed` rather than
|
|
77
|
+
* skipping the checks it cannot perform.
|
|
78
|
+
*/
|
|
79
|
+
export declare function validateReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): ReviewQueueBindingIssue[];
|
|
80
|
+
export declare function assertReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): void;
|
|
81
|
+
export type ReviewQueueExtractionIssueCode = "import-not-grounded" | "empty-queue" | "item-missing-from-queue" | "item-not-in-extraction" | "item-diverges-from-extraction";
|
|
82
|
+
export interface ReviewQueueExtractionIssue {
|
|
83
|
+
readonly code: ReviewQueueExtractionIssueCode;
|
|
84
|
+
readonly message: string;
|
|
85
|
+
readonly itemName?: string;
|
|
86
|
+
}
|
|
87
|
+
export declare class UnattestedExtractionQueueError extends Error {
|
|
88
|
+
readonly name = "UnattestedExtractionQueueError";
|
|
89
|
+
readonly issues: readonly ReviewQueueExtractionIssue[];
|
|
90
|
+
constructor(issues: readonly ReviewQueueExtractionIssue[]);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Check a stored queue for consistency with the extraction import record it
|
|
94
|
+
* was derived from.
|
|
95
|
+
*
|
|
96
|
+
* The queue binding alone cannot catch a writer who edits the queue and
|
|
97
|
+
* re-binds: hashing mutated bytes yields a self-consistent pair. This check
|
|
98
|
+
* closes the QUEUE half of that hole: it revalidates the presented record
|
|
99
|
+
* through the public import boundary (a forged `grounded` status throws; an
|
|
100
|
+
* ungrounded import is refused), re-derives the canonical ReviewItems from it,
|
|
101
|
+
* and requires the stored queue to be the SAME SET, byte-identically per item,
|
|
102
|
+
* in both directions. A queue edited independently of its record fails.
|
|
103
|
+
*
|
|
104
|
+
* What it does NOT attest: the record itself. The envelope carries the
|
|
105
|
+
* prepared artifact's digest and contentLength, never the prepared bytes, so
|
|
106
|
+
* a library handed only the record cannot verify a proposal's bytes against
|
|
107
|
+
* the digested artifact. A writer who edits the record's proposals and
|
|
108
|
+
* re-derives the queue from the edited record presents a pair this check
|
|
109
|
+
* blesses, while `result.preparedArtifact.digest` still names the honest
|
|
110
|
+
* bytes. Record integrity is therefore the caller's storage obligation, and
|
|
111
|
+
* checking prepared bytes against that digest does not meet it — the digest
|
|
112
|
+
* covers the artifact, not the proposals, so it stays green through the
|
|
113
|
+
* rewrite. Meeting it takes one of: a record digest/MAC anchored where the
|
|
114
|
+
* record's writer cannot reach, immutable or authenticated record storage,
|
|
115
|
+
* or independently re-deriving the proposals from trusted prepared bytes and
|
|
116
|
+
* comparing. This limit is pinned by a boundary test and by
|
|
117
|
+
* scripts/check-guards.mjs.
|
|
118
|
+
*
|
|
119
|
+
* This is the whole-extraction rule: it applies to a queue whose items all come
|
|
120
|
+
* from one import. A consumer whose rounds mix in items the extraction cannot
|
|
121
|
+
* attest (recheck rounds against a prior observation, for one) owns that
|
|
122
|
+
* dispatch and those semantics — deciding which attestation applies to which
|
|
123
|
+
* item from a mutable label is bypass 3, and it stays with the data that can
|
|
124
|
+
* cross-check the label.
|
|
125
|
+
*/
|
|
126
|
+
export declare function validateReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): ReviewQueueExtractionIssue[];
|
|
127
|
+
export declare function assertReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): void;
|