@kontourai/survey 2.2.3 → 2.3.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 +1 -1
- 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/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 +2 -1
- package/dist/src/review-workbench/review-workbench.js +172 -60
- package/dist/src/review-workbench/review-workbench.standalone.css +45 -11
- package/package.json +1 -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) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSoleCandidateById } from "../review-resource.js";
|
|
1
2
|
import { formatValue } from "./review-surface-preview.js";
|
|
2
3
|
export function buildReviewItemPresentation(item, adapter = {}) {
|
|
3
4
|
const context = { item };
|
|
@@ -34,7 +35,7 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
|
|
|
34
35
|
const targetLabel = item && itemContext
|
|
35
36
|
? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
|
|
36
37
|
: humanizeIdentifier(target);
|
|
37
|
-
const selectedCandidate = item
|
|
38
|
+
const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
|
|
38
39
|
return {
|
|
39
40
|
result,
|
|
40
41
|
item,
|
|
@@ -53,6 +54,32 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
|
|
|
53
54
|
: [{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" }],
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The candidate a result selected, resolved by its complete identity.
|
|
59
|
+
*
|
|
60
|
+
* `find(role === … || id === …)` returned whichever candidate matched EITHER
|
|
61
|
+
* half, so on an item carrying a repeated candidate id it could return a
|
|
62
|
+
* different candidate than the result names — presenting one candidate's value
|
|
63
|
+
* against another's decision, which is exactly what it did.
|
|
64
|
+
*
|
|
65
|
+
* The id is the identity; {@link findSoleCandidateById} makes it fail closed
|
|
66
|
+
* rather than pick a winner when it is ambiguous, and the declared role has to
|
|
67
|
+
* agree when the result states one. Falling back to the role alone is kept for
|
|
68
|
+
* results that carry no id, and requires the role to be unambiguous too.
|
|
69
|
+
*/
|
|
70
|
+
function selectedCandidateForResult(item, result) {
|
|
71
|
+
if (result.selectedCandidateId) {
|
|
72
|
+
const candidate = findSoleCandidateById(item, result.selectedCandidateId);
|
|
73
|
+
if (candidate && (result.selectedCandidateRole === undefined || candidate.role === result.selectedCandidateRole)) {
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (result.selectedCandidateRole === undefined) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const byRole = item.spec.candidates.filter((candidate) => candidate.role === result.selectedCandidateRole);
|
|
81
|
+
return byRole.length === 1 ? byRole[0] : undefined;
|
|
82
|
+
}
|
|
56
83
|
export function humanizeIdentifier(value) {
|
|
57
84
|
return value
|
|
58
85
|
.replace(/[_-]+/g, " ")
|
|
@@ -91,6 +91,17 @@ export declare function reviewSessionSummary(session: ReviewQueueSessionState):
|
|
|
91
91
|
* would invent a prior value, with provenance, that the source never had.
|
|
92
92
|
*/
|
|
93
93
|
export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boolean): ReviewWorkbenchDecision | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* The candidate a workbench decision applies to.
|
|
96
|
+
*
|
|
97
|
+
* Selection is by role, but the id this returns is what every caller makes
|
|
98
|
+
* durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
|
|
99
|
+
* replay expectation. So the id has to name exactly one candidate before it
|
|
100
|
+
* leaves here. Guarding the render path alone let the workbench emit an
|
|
101
|
+
* undecidable decision through the export path and then present a different
|
|
102
|
+
* candidate's value against it; this is the shared selector all of those go
|
|
103
|
+
* through, which is why the check belongs here rather than at each of them.
|
|
104
|
+
*/
|
|
94
105
|
export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
|
|
95
106
|
/**
|
|
96
107
|
* The value that should actually be applied for a decision: the reviewer's inline
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
|
|
2
2
|
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
3
|
-
import { reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
3
|
+
import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
4
4
|
export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
5
5
|
export const defaultReviewSessionName = "review-workbench-session";
|
|
6
6
|
export const workbenchDecisionDefinitions = {
|
|
@@ -148,12 +148,24 @@ export function keepActionDecision(item, flaggedWrong) {
|
|
|
148
148
|
}
|
|
149
149
|
return "keep-current";
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The candidate a workbench decision applies to.
|
|
153
|
+
*
|
|
154
|
+
* Selection is by role, but the id this returns is what every caller makes
|
|
155
|
+
* durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
|
|
156
|
+
* replay expectation. So the id has to name exactly one candidate before it
|
|
157
|
+
* leaves here. Guarding the render path alone let the workbench emit an
|
|
158
|
+
* undecidable decision through the export path and then present a different
|
|
159
|
+
* candidate's value against it; this is the shared selector all of those go
|
|
160
|
+
* through, which is why the check belongs here rather than at each of them.
|
|
161
|
+
*/
|
|
151
162
|
export function candidateForDecision(item, decision) {
|
|
152
163
|
const definition = workbenchDecisionDefinitions[decision];
|
|
153
164
|
const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
|
|
154
165
|
if (!candidate) {
|
|
155
166
|
throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
|
|
156
167
|
}
|
|
168
|
+
assertSoleCandidateId(item, candidate.id);
|
|
157
169
|
return candidate;
|
|
158
170
|
}
|
|
159
171
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSoleCandidateById } from "../review-resource.js";
|
|
1
2
|
export function buildSurfaceProjectionPreview(item, decision, presentationAdapter = {}) {
|
|
2
3
|
const selectedCandidate = selectedPreviewCandidate(item, decision);
|
|
3
4
|
if (!selectedCandidate || !decision) {
|
|
@@ -38,7 +39,7 @@ function selectedPreviewCandidate(item, decision) {
|
|
|
38
39
|
if (!decision?.spec.candidateId) {
|
|
39
40
|
return undefined;
|
|
40
41
|
}
|
|
41
|
-
const candidate = item
|
|
42
|
+
const candidate = findSoleCandidateById(item, decision.spec.candidateId);
|
|
42
43
|
if (!candidate) {
|
|
43
44
|
throw new Error(`ReviewItem ${item.metadata.name} has no candidate ${decision.spec.candidateId}.`);
|
|
44
45
|
}
|
|
@@ -202,11 +202,37 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
202
202
|
.survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
|
|
203
203
|
.survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
|
|
204
204
|
.survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
205
|
-
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
205
|
+
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
|
|
206
206
|
.survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
|
|
207
207
|
.survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
/* An inert link target, one per candidate in the model, so a host's
|
|
209
|
+
\`href="#<highlightElementId>"\` always resolves. It is not a control and must
|
|
210
|
+
not behave like one: no size, no tab stop, nothing painted. */
|
|
211
|
+
.survey-workbench-embed .highlight-anchor{
|
|
212
|
+
display: inline;
|
|
213
|
+
width: 0;
|
|
214
|
+
height: 0;
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* The highlight IS the return control — the only part of this surface a reader
|
|
219
|
+
can see, so it is the thing to aim at. Full phrase width, already visibly
|
|
220
|
+
marked, one tab stop per painted highlight. */
|
|
221
|
+
.survey-workbench-embed .source-highlight{
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
border-radius: 2px;
|
|
224
|
+
/* Vertical padding on an inline box grows the hit area without moving the
|
|
225
|
+
line box, and the prepared text's line-height below leaves room for it, so
|
|
226
|
+
the target reaches ~24px tall without lines overlapping each other. The
|
|
227
|
+
phrase itself supplies the width. */
|
|
228
|
+
padding: 5px 3px;
|
|
229
|
+
margin: 0 -3px;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.survey-workbench-embed .source-highlight:focus-visible{
|
|
233
|
+
outline: 3px solid var(--k-active);
|
|
234
|
+
outline-offset: 1px;
|
|
235
|
+
}
|
|
210
236
|
.survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
|
|
211
237
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
212
238
|
|
|
@@ -639,6 +665,22 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
639
665
|
display: none;
|
|
640
666
|
}
|
|
641
667
|
|
|
668
|
+
/* Why a decision was refused, next to the button that refused it. The input
|
|
669
|
+
that satisfies the precondition can be inside the collapsed audit accordion,
|
|
670
|
+
so the message cannot live only with the input (kontourai/survey#208). */
|
|
671
|
+
.survey-workbench-embed .derr{
|
|
672
|
+
display: block;
|
|
673
|
+
margin-top: 6px;
|
|
674
|
+
text-align: right;
|
|
675
|
+
font-size: 12px;
|
|
676
|
+
font-weight: 600;
|
|
677
|
+
color: var(--k-negative);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.survey-workbench-embed .derr[hidden]{
|
|
681
|
+
display: none;
|
|
682
|
+
}
|
|
683
|
+
|
|
642
684
|
/* confidence + provenance */
|
|
643
685
|
|
|
644
686
|
.survey-workbench-embed .prov{
|
|
@@ -1080,14 +1122,6 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
1080
1122
|
text-transform: uppercase;
|
|
1081
1123
|
}
|
|
1082
1124
|
|
|
1083
|
-
.survey-workbench-embed .preview-section.is-neutral{
|
|
1084
|
-
background: var(--k-raised);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
.survey-workbench-embed .preview-section.is-neutral h3{
|
|
1088
|
-
color: var(--k-faint);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
1125
|
.survey-workbench-embed .reference-details{
|
|
1092
1126
|
margin-top: 8px;
|
|
1093
1127
|
}
|
|
@@ -298,7 +298,30 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
|
298
298
|
if (this.#session.activeItemName !== item.metadata.name)
|
|
299
299
|
this.#session = { ...this.#session, activeItemName: item.metadata.name };
|
|
300
300
|
this.#remount();
|
|
301
|
-
|
|
301
|
+
// Both lookups below are published contracts, used as published.
|
|
302
|
+
// Reconstructing the element id from candidateId — which this did — meant
|
|
303
|
+
// carrying a copy of Survey's private id sanitizer and ignoring the
|
|
304
|
+
// collision suffix that makes the id unique, which is exactly what the
|
|
305
|
+
// consumer guide tells embedders not to do.
|
|
306
|
+
const { highlightElementId } = detail;
|
|
307
|
+
if (!highlightElementId)
|
|
308
|
+
return;
|
|
309
|
+
queueMicrotask(() => {
|
|
310
|
+
// Routed on the highlight element id, which is unique by construction.
|
|
311
|
+
// candidateId is the candidate's own identity and a caller-authored
|
|
312
|
+
// model may repeat it, so a `[data-…="<candidateId>"]` lookup can select
|
|
313
|
+
// a different candidate's highlight — confidently wrong, on the surface
|
|
314
|
+
// whose job is showing which span a value came from. `~=` matches one
|
|
315
|
+
// whitespace-separated token, so a mark over a shared span is found too.
|
|
316
|
+
const highlight = this.#root.querySelector(`[data-highlight-return-to~="${CSS.escape(highlightElementId)}"]`);
|
|
317
|
+
if (highlight) {
|
|
318
|
+
highlight.focus();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Off the current page there is nothing painted; bring the link target
|
|
322
|
+
// into view instead of leaving the reader where they were.
|
|
323
|
+
this.#root.querySelector(`#${CSS.escape(highlightElementId)}`)?.scrollIntoView({ block: "center" });
|
|
324
|
+
});
|
|
302
325
|
});
|
|
303
326
|
}
|
|
304
327
|
/** The review queue session to display. Setting this property re-mounts the workbench. */
|
|
@@ -199,11 +199,37 @@
|
|
|
199
199
|
.survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
|
|
200
200
|
.survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
|
|
201
201
|
.survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
202
|
-
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
202
|
+
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
|
|
203
203
|
.survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
|
|
204
204
|
.survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
/* An inert link target, one per candidate in the model, so a host's
|
|
206
|
+
`href="#<highlightElementId>"` always resolves. It is not a control and must
|
|
207
|
+
not behave like one: no size, no tab stop, nothing painted. */
|
|
208
|
+
.survey-workbench-embed .highlight-anchor{
|
|
209
|
+
display: inline;
|
|
210
|
+
width: 0;
|
|
211
|
+
height: 0;
|
|
212
|
+
overflow: hidden;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* The highlight IS the return control — the only part of this surface a reader
|
|
216
|
+
can see, so it is the thing to aim at. Full phrase width, already visibly
|
|
217
|
+
marked, one tab stop per painted highlight. */
|
|
218
|
+
.survey-workbench-embed .source-highlight{
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
border-radius: 2px;
|
|
221
|
+
/* Vertical padding on an inline box grows the hit area without moving the
|
|
222
|
+
line box, and the prepared text's line-height below leaves room for it, so
|
|
223
|
+
the target reaches ~24px tall without lines overlapping each other. The
|
|
224
|
+
phrase itself supplies the width. */
|
|
225
|
+
padding: 5px 3px;
|
|
226
|
+
margin: 0 -3px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.survey-workbench-embed .source-highlight:focus-visible{
|
|
230
|
+
outline: 3px solid var(--k-active);
|
|
231
|
+
outline-offset: 1px;
|
|
232
|
+
}
|
|
207
233
|
.survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
|
|
208
234
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
209
235
|
|
|
@@ -636,6 +662,22 @@
|
|
|
636
662
|
display: none;
|
|
637
663
|
}
|
|
638
664
|
|
|
665
|
+
/* Why a decision was refused, next to the button that refused it. The input
|
|
666
|
+
that satisfies the precondition can be inside the collapsed audit accordion,
|
|
667
|
+
so the message cannot live only with the input (kontourai/survey#208). */
|
|
668
|
+
.survey-workbench-embed .derr{
|
|
669
|
+
display: block;
|
|
670
|
+
margin-top: 6px;
|
|
671
|
+
text-align: right;
|
|
672
|
+
font-size: 12px;
|
|
673
|
+
font-weight: 600;
|
|
674
|
+
color: var(--k-negative);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.survey-workbench-embed .derr[hidden]{
|
|
678
|
+
display: none;
|
|
679
|
+
}
|
|
680
|
+
|
|
639
681
|
/* confidence + provenance */
|
|
640
682
|
|
|
641
683
|
.survey-workbench-embed .prov{
|
|
@@ -1077,14 +1119,6 @@
|
|
|
1077
1119
|
text-transform: uppercase;
|
|
1078
1120
|
}
|
|
1079
1121
|
|
|
1080
|
-
.survey-workbench-embed .preview-section.is-neutral{
|
|
1081
|
-
background: var(--k-raised);
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
.survey-workbench-embed .preview-section.is-neutral h3{
|
|
1085
|
-
color: var(--k-faint);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
1122
|
.survey-workbench-embed .reference-details{
|
|
1089
1123
|
margin-top: 8px;
|
|
1090
1124
|
}
|
|
@@ -2,7 +2,8 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
|
|
|
2
2
|
import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
|
|
3
3
|
import { type ReviewPresentationAdapter } from "./review-presentation.js";
|
|
4
4
|
import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
|
|
5
|
-
export {
|
|
5
|
+
export { reviewAuditRowKeys, type ReviewAuditRowKey } from "./audit-rows.js";
|
|
6
|
+
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type BuiltExtractionInspectorCandidate, type BuiltExtractionInspectorModel, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
|
|
6
7
|
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
|
|
7
8
|
export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
|
|
8
9
|
export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
|