@larc-iu/plaid-client 0.2.0-alpha.1 → 0.2.0-alpha.11

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/src/provenance.js CHANGED
@@ -5,28 +5,53 @@
5
5
  * Flat scalar keys — the query engine matches flat metadata well, nested
6
6
  * objects poorly.
7
7
  *
8
- * Three states (the PRESENCE of the `prov` key, not its value, is the
9
- * discriminator; absent keys mean "human"):
8
+ * Two axes. ORIGIN is the `prov` key: absent means a trusted person (a
9
+ * verifier) made it; 'inferred' means an algorithm or service did;
10
+ * 'contributed' means a person whose work the project reviews (a
11
+ * contributor) did. TRUST is `provConfirmed`: true once a verifier vouched
12
+ * for the value. Together they give four states:
10
13
  *
11
- * human — no prov keys; a person made it.
14
+ * human — no prov keys; a verifier made it.
12
15
  * machine — { prov: 'inferred', provSource: '<producer>' };
13
- * an algorithm/service made it, no human has vouched.
14
- * verified — same + { provConfirmed: true }; machine-made and a
15
- * human confirmed (or edited) it. provSource stays so
16
- * machine origin remains traceable.
16
+ * an algorithm/service made it, nobody has vouched.
17
+ * contributed — { prov: 'contributed', provSource: 'user:<id>' };
18
+ * a contributor made it, no verifier has vouched.
19
+ * verified — either origin + { provConfirmed: true }; a verifier
20
+ * confirmed (or edited) it. prov/provSource stay so
21
+ * the origin remains traceable.
17
22
  *
18
23
  * The write contract every machine writer must follow:
19
24
  * 1. Machine writers may freely replace MACHINE (unverified) material.
20
- * 2. Machine writers must never modify or delete human or verified
21
- * material unless explicitly told to overwrite (an explicit, per-run,
22
- * user-facing opt-in — for services, a declared boolean `overwrite`
23
- * parameter).
24
- * 3. Any human edit of a machine annotation verifies it: the edit also
25
- * stamps { provConfirmed: true } (see verifyOnEdit).
25
+ * 2. Machine writers must never modify or delete human, contributed or
26
+ * verified material unless explicitly told to overwrite (an explicit,
27
+ * per-run, user-facing opt-in — for services, a declared boolean
28
+ * `overwrite` parameter). A contributor's work is a person's work.
29
+ * 3. A verifier's edit of machine or contributed material verifies it:
30
+ * the edit also stamps { provConfirmed: true } (see verifyOnEdit).
31
+ * A contributor's edit of anything marks it contributed: the edit
32
+ * merges stampContributed(userId) and drops any earlier confirmation
33
+ * (see contributeOnEdit). Who is a contributor is the PROJECT's call,
34
+ * recorded once for every app (see "Review" below); a service running
35
+ * as a contributor should stamp likewise.
36
+ *
37
+ * REVIEW: which people are contributors. A project records it under the
38
+ * reserved `plaid` config namespace, so every app that writes on a person's
39
+ * behalf reads the same answer:
40
+ *
41
+ * config.plaid.review = { users: [userId, ...], roles: ['writer', ...] }
42
+ *
43
+ * `users` names people whose work is reviewed whatever their role; `roles`
44
+ * names whole project roles ('reader' | 'writer' | 'maintainer'; an admin
45
+ * without an explicit role counts as a maintainer). Either may be absent.
46
+ * Nothing here grants or denies access: the ACL lists stay the permission
47
+ * model, and this only says whose work needs a verifier's look. Apps expose
48
+ * it as a per-member mark on their access screens (readReview, isReviewed,
49
+ * withReviewedUser) and derive the writer's policy from it (writerPolicy).
26
50
  *
27
51
  * Producer naming: 'service:<serviceId>' for services (use serviceSource),
28
- * 'rule:<name>' for built-in rule algorithms, app-specific ids like
29
- * 'gloss:doc-frequency' or 'flex-import' otherwise.
52
+ * 'rule:<name>' for built-in rule algorithms, 'user:<userId>' for a
53
+ * contributor (use userSource), app-specific ids like 'gloss:doc-frequency'
54
+ * or 'flex-import' otherwise.
30
55
  *
31
56
  * PREDICTION EXTRAS. A producer may also record how confident it was and
32
57
  * what else it considered, in two reserved slots split along the
@@ -43,6 +68,17 @@
43
68
  * human edits (history is valuable), so a consumer must not present
44
69
  * provProb as confidence in the CURRENT value once the entity is verified
45
70
  * (provConfirmed is exactly the flag to check).
71
+ *
72
+ * Recommended provDetail keys, so consumers (review dashboards, ranked
73
+ * alternatives) can read any producer's output:
74
+ * - value (spans) / form (morpheme tokens): the value the producer
75
+ * wrote, mirroring the entity's own field. The entity may be edited
76
+ * later; this copy is what makes "accepted as-is" vs "corrected"
77
+ * answerable once the entity is verified.
78
+ * - valueProbs / formProbs: a top-k {label: probability} map over the
79
+ * alternatives the producer considered, the chosen label included
80
+ * (provProb, when given, is that label's entry duplicated flat).
81
+ * - model, and anything else (language, adapter, raw scores).
46
82
  */
47
83
 
48
84
  /** The flat metadata keys of the provenance convention. */
@@ -57,14 +93,17 @@ export const PROV = Object.freeze({
57
93
  probKey: 'provProb',
58
94
  /** Open map of producer extras (alternatives, model version, raw scores). */
59
95
  detailKey: 'provDetail',
60
- /** The (currently only) value of `prov`. Presence, not value, decides. */
96
+ /** `prov` for machine-made material. Any value but CONTRIBUTED reads as machine. */
61
97
  INFERRED: 'inferred',
98
+ /** `prov` for a contributor's material. */
99
+ CONTRIBUTED: 'contributed',
62
100
  });
63
101
 
64
- /** The three provenance states returned by provState. */
102
+ /** The four provenance states returned by provState. */
65
103
  export const PROV_STATES = Object.freeze({
66
104
  HUMAN: 'human',
67
105
  MACHINE: 'machine',
106
+ CONTRIBUTED: 'contributed',
68
107
  VERIFIED: 'verified',
69
108
  });
70
109
 
@@ -105,35 +144,228 @@ export const confirmedInferred = (source, extras = {}) => ({
105
144
  */
106
145
  export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
107
146
 
147
+ /**
148
+ * The metadata fragment a contributor's work carries:
149
+ * { prov: 'contributed', provSource: 'user:<userId>' }.
150
+ * @param {string} userId
151
+ */
152
+ export const stampContributed = (userId) => ({
153
+ [PROV.key]: PROV.CONTRIBUTED,
154
+ [PROV.sourceKey]: userSource(userId),
155
+ });
156
+
108
157
  /**
109
158
  * Classify an entity's metadata into one of PROV_STATES.
110
159
  * @param {Object|null|undefined} metadata
111
- * @returns {'human'|'machine'|'verified'}
160
+ * @returns {'human'|'machine'|'contributed'|'verified'}
112
161
  */
113
162
  export const provState = (metadata) => {
114
163
  if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
115
- return metadata[PROV.confirmedKey] ? PROV_STATES.VERIFIED : PROV_STATES.MACHINE;
164
+ if (metadata[PROV.confirmedKey]) return PROV_STATES.VERIFIED;
165
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV_STATES.CONTRIBUTED : PROV_STATES.MACHINE;
166
+ };
167
+
168
+ /**
169
+ * Where an entity came from, confirmed or not: null for a verifier's own
170
+ * work, PROV.INFERRED for a machine's, PROV.CONTRIBUTED for a contributor's.
171
+ * What a verified entity's tooltip needs, since provState folds both
172
+ * origins into 'verified'.
173
+ * @returns {null|'inferred'|'contributed'}
174
+ */
175
+ export const provOrigin = (metadata) => {
176
+ if (!metadata || metadata[PROV.key] == null) return null;
177
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV.CONTRIBUTED : PROV.INFERRED;
116
178
  };
117
179
 
118
180
  /**
119
- * Whether this entity is machine-made and not yet human-verified — the
120
- * material that still needs review, that machine writers may replace, and
121
- * that a human confirmation gesture acts on. Complement of isProtected.
181
+ * Whether this entity is machine-made and not yet verified — the material
182
+ * machine writers may replace. Complement of isProtected.
122
183
  */
123
184
  export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
124
185
 
125
186
  /**
126
187
  * Whether a machine writer must leave this entity alone (write-contract
127
- * rule 2): true for human-made and human-verified material.
188
+ * rule 2): true for human-made, contributed and verified material.
128
189
  */
129
190
  export const isProtected = (metadata) => !isMachine(metadata);
130
191
 
131
192
  /**
132
- * The metadata fragment a HUMAN edit of this entity should merge in
133
- * (write-contract rule 3): PROV_CONFIRMED when the entity is machine-made
134
- * and unverified, else null (nothing to do).
193
+ * Whether a verifier still has to look at this entity: machine-made or
194
+ * contributed, and not yet confirmed. What review UIs mark and sweep, and
195
+ * what a verifier's confirmation gesture acts on.
135
196
  */
136
- export const verifyOnEdit = (metadata) => (isMachine(metadata) ? PROV_CONFIRMED : null);
197
+ export const needsReview = (metadata) => {
198
+ const s = provState(metadata);
199
+ return s === PROV_STATES.MACHINE || s === PROV_STATES.CONTRIBUTED;
200
+ };
201
+
202
+ /**
203
+ * The metadata fragment a VERIFIER's edit of this entity should merge in
204
+ * (write-contract rule 3): PROV_CONFIRMED when the entity needs review,
205
+ * else null (nothing to do).
206
+ */
207
+ export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRMED : null);
208
+
209
+ /**
210
+ * The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
211
+ * (write-contract rule 3): the contributed stamp, plus provConfirmed: null
212
+ * so an earlier confirmation is dropped (a patch deletes null-valued keys;
213
+ * see mergeMetadata for a full replace). Prediction extras a machine
214
+ * recorded stay, as history.
215
+ * @param {Object|null|undefined} metadata - unused today; the fragment is
216
+ * the same whatever the entity was, and the parameter keeps the shape of
217
+ * verifyOnEdit so callers can swap one for the other
218
+ * @param {string} userId
219
+ */
220
+ // eslint-disable-next-line no-unused-vars
221
+ export const contributeOnEdit = (metadata, userId) => ({
222
+ ...stampContributed(userId),
223
+ [PROV.confirmedKey]: null,
224
+ });
225
+
226
+ /**
227
+ * Merge a metadata fragment the way the server's PATCH does — a null value
228
+ * deletes the key — for callers that keep a local copy or send a full
229
+ * replacement (setMetadata). Returns a new object.
230
+ */
231
+ export const mergeMetadata = (metadata, fragment) => {
232
+ const out = { ...(metadata || {}) };
233
+ for (const [k, v] of Object.entries(fragment || {})) {
234
+ if (v === null) delete out[k];
235
+ else out[k] = v;
236
+ }
237
+ return out;
238
+ };
137
239
 
138
240
  /** Canonical provSource for a service: 'service:<serviceId>'. */
139
241
  export const serviceSource = (serviceId) => `service:${serviceId}`;
242
+
243
+ /** Canonical provSource for a contributor: 'user:<userId>'. */
244
+ export const userSource = (userId) => `user:${userId}`;
245
+
246
+ // ---- review: whose work is reviewed (a project-config norm) ----
247
+
248
+ /** The config key, under the `plaid` namespace, holding the review lists. */
249
+ export const REVIEW_KEY = 'review';
250
+
251
+ /** The project roles a review list may name. */
252
+ export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
253
+
254
+ /**
255
+ * The project's review lists, normalized: { users: string[], roles: string[] }.
256
+ * @param {Object|null|undefined} config - a project's `config`
257
+ */
258
+ export const readReview = (config) => {
259
+ const raw = config?.plaid?.[REVIEW_KEY];
260
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
261
+ return { users: list(raw?.users), roles: list(raw?.roles) };
262
+ };
263
+
264
+ /**
265
+ * A person's role in a project from its ACL lists: 'maintainer' | 'writer' |
266
+ * 'reader' | null. An admin with no explicit entry is a maintainer, as the
267
+ * permission model treats them.
268
+ * @param {Object} project - with `maintainers`, `writers`, `readers`
269
+ * @param {string} userId
270
+ * @param {Object} [opts]
271
+ * @param {boolean} [opts.isAdmin]
272
+ */
273
+ export const projectRole = (project, userId, { isAdmin = false } = {}) => {
274
+ const inList = (l) => Array.isArray(l) && userId != null && l.includes(userId);
275
+ if (inList(project?.maintainers)) return 'maintainer';
276
+ if (inList(project?.writers)) return 'writer';
277
+ if (inList(project?.readers)) return 'reader';
278
+ return isAdmin ? 'maintainer' : null;
279
+ };
280
+
281
+ /**
282
+ * Whether this person's work is reviewed in this project: named in
283
+ * review.users, or holding a role named in review.roles.
284
+ */
285
+ export const isReviewed = (project, userId, { isAdmin = false } = {}) => {
286
+ if (!project || userId == null) return false;
287
+ const { users, roles } = readReview(project.config);
288
+ if (users.includes(userId)) return true;
289
+ const role = projectRole(project, userId, { isAdmin });
290
+ return role != null && roles.includes(role);
291
+ };
292
+
293
+ /**
294
+ * The review lists with one person added to or removed from `users`
295
+ * (`roles` untouched). Pure; write the result with
296
+ * projects.setConfig(id, 'plaid', REVIEW_KEY, next).
297
+ */
298
+ export const withReviewedUser = (review, userId, reviewed) => {
299
+ const { users, roles } = readReview({ plaid: { [REVIEW_KEY]: review } });
300
+ const next = users.filter((u) => u !== userId);
301
+ if (reviewed) next.push(userId);
302
+ return { users: next, roles };
303
+ };
304
+
305
+ // ---- the writer's policy: what a person's writes carry ----
306
+
307
+ /**
308
+ * What one writer's writes carry and what their review gestures act on,
309
+ * given who they are: `contributorId` is their user id when their work is
310
+ * reviewed (isReviewed), else null for a verifier. Apps derive this once per
311
+ * document and route every human write through it, so the convention's
312
+ * rule 3 has one implementation.
313
+ *
314
+ * @param {string|null} contributorId
315
+ * @returns {{
316
+ * contributorId: string|null,
317
+ * isContributor: boolean,
318
+ * createStamp: Object|null,
319
+ * editStamp: (metadata: Object) => Object|null,
320
+ * confirmStamp: (metadata: Object) => Object|null,
321
+ * adoptStamp: (source: string, detail?: Object) => Object,
322
+ * reviewable: (metadata: Object) => boolean,
323
+ * reviewableState: (state: string) => boolean,
324
+ * }}
325
+ */
326
+ export const writerPolicy = (contributorId = null) => {
327
+ const id = contributorId || null;
328
+ const contributor = id != null;
329
+ // Material this writer's review gestures act on: a verifier reviews
330
+ // machine and contributed material; a contributor reviews machine
331
+ // proposals only, since their own vouching is itself a contribution.
332
+ const reviewable = (metadata) => (contributor ? isMachine(metadata) : needsReview(metadata));
333
+ const reviewableState = (state) =>
334
+ contributor
335
+ ? state === PROV_STATES.MACHINE
336
+ : state === PROV_STATES.MACHINE || state === PROV_STATES.CONTRIBUTED;
337
+ return Object.freeze({
338
+ contributorId: id,
339
+ isContributor: contributor,
340
+ /** The metadata a NEW entity carries: null for a verifier. */
341
+ createStamp: contributor ? stampContributed(id) : null,
342
+ /**
343
+ * The fragment an EDIT merges over the entity's metadata, or null when
344
+ * there is nothing to merge: a verifier confirms what needs review; a
345
+ * contributor's edit marks the entity contributed, dropping any earlier
346
+ * confirmation.
347
+ */
348
+ editStamp: (metadata) => (contributor ? contributeOnEdit(metadata, id) : verifyOnEdit(metadata)),
349
+ /**
350
+ * The fragment an explicit confirm gesture merges, or null when there is
351
+ * nothing for this writer to confirm: PROV_CONFIRMED for a verifier, the
352
+ * contributed stamp for a contributor accepting a machine proposal.
353
+ */
354
+ confirmStamp: (metadata) => {
355
+ if (!reviewable(metadata)) return null;
356
+ return contributor ? contributeOnEdit(metadata, id) : PROV_CONFIRMED;
357
+ },
358
+ /**
359
+ * What an adopted suggestion is written with (a guess or a picked value
360
+ * into an empty cell): born-verified with the suggestion's producer as
361
+ * its source for a verifier; contributed for a contributor, with the
362
+ * producer kept as provDetail.guess. `detail` is the prediction extras.
363
+ */
364
+ adoptStamp: (source, detail) =>
365
+ contributor
366
+ ? { ...stampContributed(id), [PROV.detailKey]: { ...(detail || {}), guess: source } }
367
+ : confirmedInferred(source, { detail }),
368
+ reviewable,
369
+ reviewableState,
370
+ });
371
+ };
@@ -43,11 +43,17 @@
43
43
  * without an explicit `overwrite` parameter. See the manual, "Provenance".
44
44
  */
45
45
  export const TASKS = Object.freeze({
46
- TOKENIZE: 'tokenize',
47
- PARSE: 'parse',
48
- TRANSCRIBE: 'transcribe',
46
+ TOKENIZE: "tokenize",
47
+ PARSE: "parse",
48
+ TRANSCRIBE: "transcribe",
49
49
  /** Create vocab links for unlinked tokens. */
50
- LINK_VOCAB: 'link-vocab',
50
+ LINK_VOCAB: "link-vocab",
51
+ /** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
52
+ ANALYZE: "analyze",
53
+ /** Propose a free translation for each sentence. */
54
+ TRANSLATE: "translate",
55
+ /** A conversational assistant over a project (chat turns; see plaid-igt-agent). */
56
+ ASSIST: "assist",
51
57
  });
52
58
 
53
59
  /**
@@ -55,8 +61,8 @@ export const TASKS = Object.freeze({
55
61
  * declared `tasks` array. Drop once all services advertise `tasks`.
56
62
  */
57
63
  const LEGACY_TASK_PREFIXES = Object.freeze({
58
- [TASKS.TOKENIZE]: 'tok:',
59
- [TASKS.TRANSCRIBE]: 'asr:',
64
+ [TASKS.TOKENIZE]: "tok:",
65
+ [TASKS.TRANSCRIBE]: "asr:",
60
66
  });
61
67
 
62
68
  /**
@@ -72,7 +78,11 @@ export function servesTask(service, task) {
72
78
  return declared.includes(task);
73
79
  }
74
80
  const prefix = LEGACY_TASK_PREFIXES[task];
75
- return !!prefix && typeof service?.serviceId === 'string' && service.serviceId.startsWith(prefix);
81
+ return (
82
+ !!prefix &&
83
+ typeof service?.serviceId === "string" &&
84
+ service.serviceId.startsWith(prefix)
85
+ );
76
86
  }
77
87
 
78
88
  /**
@@ -102,7 +112,7 @@ export function getParamSchema(service) {
102
112
  * @returns {string}
103
113
  */
104
114
  export function getServiceSummary(service) {
105
- return service?.extras?.summary || service?.description || '';
115
+ return service?.extras?.summary || service?.description || "";
106
116
  }
107
117
 
108
118
  /** Valid option values for an enum/multiselect param. */
@@ -117,20 +127,25 @@ function optionValues(param) {
117
127
  */
118
128
  function defaultForParam(param) {
119
129
  const opts = optionValues(param);
120
- if (param.type === 'enum') {
121
- if (param.default != null && opts.includes(param.default)) return param.default;
122
- return opts[0] ?? '';
130
+ if (param.type === "enum") {
131
+ if (param.default != null && opts.includes(param.default))
132
+ return param.default;
133
+ return opts[0] ?? "";
123
134
  }
124
- if (param.type === 'multiselect') {
135
+ if (param.type === "multiselect") {
125
136
  const arr = Array.isArray(param.default) ? param.default : [];
126
137
  return opts.length ? arr.filter((x) => opts.includes(x)) : arr;
127
138
  }
128
- if (param.default !== undefined && param.default !== null) return param.default;
139
+ if (param.default !== undefined && param.default !== null)
140
+ return param.default;
129
141
  switch (param.type) {
130
- case 'number': return typeof param.min === 'number' ? param.min : 0;
131
- case 'boolean': return false;
132
- case 'string':
133
- default: return '';
142
+ case "number":
143
+ return typeof param.min === "number" ? param.min : 0;
144
+ case "boolean":
145
+ return false;
146
+ case "string":
147
+ default:
148
+ return "";
134
149
  }
135
150
  }
136
151
 
@@ -167,43 +182,48 @@ export function coerceParamValues(schema, raw) {
167
182
  if (v === undefined) v = defaultForParam(param);
168
183
 
169
184
  switch (param.type) {
170
- case 'number': {
185
+ case "number": {
171
186
  // Blank / nullish counts as "missing" → the param's default (matches the
172
187
  // Python client, where float('') raises and falls back to the default).
173
188
  let n;
174
- if (v === '' || v == null || (typeof v === 'string' && v.trim() === '')) {
189
+ if (
190
+ v === "" ||
191
+ v == null ||
192
+ (typeof v === "string" && v.trim() === "")
193
+ ) {
175
194
  n = defaultForParam(param);
176
195
  } else {
177
- n = typeof v === 'number' ? v : Number(v);
196
+ n = typeof v === "number" ? v : Number(v);
178
197
  if (Number.isNaN(n)) n = defaultForParam(param);
179
198
  }
180
- if (typeof param.min === 'number') n = Math.max(param.min, n);
181
- if (typeof param.max === 'number') n = Math.min(param.max, n);
199
+ if (typeof param.min === "number") n = Math.max(param.min, n);
200
+ if (typeof param.max === "number") n = Math.min(param.max, n);
182
201
  v = n;
183
202
  break;
184
203
  }
185
- case 'boolean':
186
- v = v === true || v === 'true';
204
+ case "boolean":
205
+ v = v === true || v === "true";
187
206
  break;
188
- case 'enum': {
207
+ case "enum": {
189
208
  const opts = optionValues(param);
190
209
  if (opts.length && !opts.includes(v)) v = defaultForParam(param);
191
210
  break;
192
211
  }
193
- case 'multiselect': {
212
+ case "multiselect": {
194
213
  const opts = optionValues(param);
195
- const arr = Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]);
214
+ const arr = Array.isArray(v) ? v : v == null || v === "" ? [] : [v];
196
215
  v = opts.length ? arr.filter((x) => opts.includes(x)) : arr;
197
216
  break;
198
217
  }
199
- case 'string':
218
+ case "string":
200
219
  default:
201
- v = v == null ? '' : String(v);
220
+ v = v == null ? "" : String(v);
202
221
  break;
203
222
  }
204
223
 
205
224
  if (param.required) {
206
- const empty = v === '' || v == null || (Array.isArray(v) && v.length === 0);
225
+ const empty =
226
+ v === "" || v == null || (Array.isArray(v) && v.length === 0);
207
227
  if (empty) errors[k] = `${param.label || k} is required`;
208
228
  }
209
229
  values[k] = v;