@larc-iu/plaid-client 0.1.0-alpha.8 → 0.2.0-alpha.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Provenance: the cross-app convention for distinguishing machine-provided
3
+ * from human-labeled information, expressed as flat metadata keys on
4
+ * annotation entities (spans, relations, vocab links, optionally tokens).
5
+ * Flat scalar keys — the query engine matches flat metadata well, nested
6
+ * objects poorly.
7
+ *
8
+ * Three states (the PRESENCE of the `prov` key, not its value, is the
9
+ * discriminator; absent keys mean "human"):
10
+ *
11
+ * human — no prov keys; a person made it.
12
+ * 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.
17
+ *
18
+ * The write contract every machine writer must follow:
19
+ * 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).
26
+ *
27
+ * 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.
30
+ *
31
+ * PREDICTION EXTRAS. A producer may also record how confident it was and
32
+ * what else it considered, in two reserved slots split along the
33
+ * queryability line:
34
+ * - provProb: ONE flat number in [0, 1] — the producer's probability for
35
+ * the value it chose. Flat scalars are what the query engine filters
36
+ * and orders on, so "review the least-confident machine output first"
37
+ * is an ordinary query. Omit it unless you can honestly produce a
38
+ * probability (a raw logprob is NOT one — put it in provDetail).
39
+ * - provDetail: ONE open map for everything else — top-k alternatives or
40
+ * distributions, model name/version, raw scores. Deliberately nested
41
+ * (not queryable); keep it small (top-k, not whole-vocabulary dumps).
42
+ * Both describe the machine's ORIGINAL prediction. They are kept after
43
+ * human edits (history is valuable), so a consumer must not present
44
+ * provProb as confidence in the CURRENT value once the entity is verified
45
+ * (provConfirmed is exactly the flag to check).
46
+ */
47
+
48
+ /** The flat metadata keys of the provenance convention. */
49
+ export const PROV = Object.freeze({
50
+ /** 'inferred' when produced by an algorithm/service; absent = human. */
51
+ key: 'prov',
52
+ /** Which producer, e.g. 'service:<id>', 'rule:precedent-or-unique'. */
53
+ sourceKey: 'provSource',
54
+ /** true once a human confirmed (or edited) the inferred value. */
55
+ confirmedKey: 'provConfirmed',
56
+ /** Producer's probability ([0,1]) for the chosen value. Flat = queryable. */
57
+ probKey: 'provProb',
58
+ /** Open map of producer extras (alternatives, model version, raw scores). */
59
+ detailKey: 'provDetail',
60
+ /** The (currently only) value of `prov`. Presence, not value, decides. */
61
+ INFERRED: 'inferred',
62
+ });
63
+
64
+ /** The three provenance states returned by provState. */
65
+ export const PROV_STATES = Object.freeze({
66
+ HUMAN: 'human',
67
+ MACHINE: 'machine',
68
+ VERIFIED: 'verified',
69
+ });
70
+
71
+ /**
72
+ * The metadata fragment a machine writer merges into everything it creates.
73
+ * @param {string} source - producer id, e.g. serviceSource(serviceId)
74
+ * @param {Object} [extras] - optional prediction extras:
75
+ * @param {number} [extras.prob] - probability in [0,1] for the chosen value
76
+ * (omit unless it honestly is one — raw scores go in detail)
77
+ * @param {Object} [extras.detail] - open map of producer extras (top-k
78
+ * alternatives, model version, raw scores; keep it small)
79
+ */
80
+ export const stampInferred = (source, { prob, detail } = {}) => ({
81
+ [PROV.key]: PROV.INFERRED,
82
+ [PROV.sourceKey]: source,
83
+ ...(prob !== undefined && prob !== null ? { [PROV.probKey]: prob } : {}),
84
+ ...(detail ? { [PROV.detailKey]: detail } : {}),
85
+ });
86
+
87
+ /**
88
+ * The fragment for machine-made material that is born verified (e.g. an
89
+ * import carrying upstream human approval, or a guess written only on
90
+ * explicit user confirmation). Takes the same optional prediction extras
91
+ * as stampInferred.
92
+ */
93
+ export const confirmedInferred = (source, extras = {}) => ({
94
+ ...stampInferred(source, extras),
95
+ [PROV.confirmedKey]: true,
96
+ });
97
+
98
+ /**
99
+ * Classify an entity's metadata into one of PROV_STATES.
100
+ * @param {Object|null|undefined} metadata
101
+ * @returns {'human'|'machine'|'verified'}
102
+ */
103
+ export const provState = (metadata) => {
104
+ if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
105
+ return metadata[PROV.confirmedKey] ? PROV_STATES.VERIFIED : PROV_STATES.MACHINE;
106
+ };
107
+
108
+ /**
109
+ * Whether a machine writer must leave this entity alone (write-contract
110
+ * rule 2): true for human-made and human-verified material.
111
+ */
112
+ export const isProtected = (metadata) => provState(metadata) !== PROV_STATES.MACHINE;
113
+
114
+ /**
115
+ * The metadata fragment a HUMAN edit of this entity should merge in
116
+ * (write-contract rule 3): { provConfirmed: true } when the entity is
117
+ * machine-made and unverified, else null (nothing to do).
118
+ */
119
+ export const verifyOnEdit = (metadata) =>
120
+ (provState(metadata) === PROV_STATES.MACHINE ? { [PROV.confirmedKey]: true } : null);
121
+
122
+ /** Canonical provSource for a service: 'service:<serviceId>'. */
123
+ export const serviceSource = (serviceId) => `service:${serviceId}`;
package/src/roles.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared layer-role vocabulary for cross-app interoperability.
3
+ *
4
+ * Apps that share a Plaid project agree on the *substrate* — the text and token
5
+ * layers — by tagging each shared layer with a ROLE under the reserved `plaid`
6
+ * config namespace (`config.plaid.role`, a scalar). Annotations stay private to
7
+ * each app under that app's own namespace. See the Plaid manual, "Layer
8
+ * Interoperability". The role inventory is small and fixed:
9
+ *
10
+ * baseline the primary text layer
11
+ * sentence sentence token layer
12
+ * word orthographic-word token layer (CoNLL-U "token")
13
+ * syntactic-word grammatical words below the word (CoNLL-U "word" / MWT splits)
14
+ * morpheme morpheme token layer
15
+ * time-alignment media-timeline token layer
16
+ *
17
+ * Only these values are understood across apps; an app may store any string but
18
+ * loses interoperability for unknown values.
19
+ */
20
+
21
+ /** The reserved config namespace for cross-app conventions. */
22
+ export const PLAID_NAMESPACE = 'plaid';
23
+
24
+ /** The config key, under `plaid`, holding a layer's role. */
25
+ export const ROLE_KEY = 'role';
26
+
27
+ /** The fixed role inventory. */
28
+ export const ROLES = Object.freeze({
29
+ BASELINE: 'baseline',
30
+ SENTENCE: 'sentence',
31
+ WORD: 'word',
32
+ SYNTACTIC_WORD: 'syntactic-word',
33
+ MORPHEME: 'morpheme',
34
+ TIME_ALIGNMENT: 'time-alignment',
35
+ });
36
+
37
+ /**
38
+ * The role recorded on a layer's `config`, or null if none.
39
+ * @param {object} [config] a layer's `config` object
40
+ * @returns {string|null}
41
+ */
42
+ export function readRole(config) {
43
+ const v = config?.[PLAID_NAMESPACE]?.[ROLE_KEY];
44
+ return v == null ? null : v;
45
+ }
46
+
47
+ /**
48
+ * The first layer in `layers` carrying the given role, or null. The single
49
+ * "find a layer by its role" primitive — apps build their named finders
50
+ * (findWordTokenLayer, etc.) on top of this.
51
+ * @param {Array<{config?: object}>} [layers]
52
+ * @param {string} role
53
+ * @returns {object|null}
54
+ */
55
+ export function findByRole(layers, role) {
56
+ return (layers || []).find(l => readRole(l?.config) === role) || null;
57
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Service self-description: tasks, summary, and a parameter schema.
3
+ *
4
+ * The service framework's transport carries an opaque `extras` JSON map on every
5
+ * registered service (see services.js). This module standardizes what a service
6
+ * advertises in that map so apps can, at a fixed integration point (a "task"
7
+ * like tokenize / parse / transcribe):
8
+ *
9
+ * 1. SELECT one of several services that serve the task,
10
+ * 2. let the user SPECIFY arguments the service declares, and
11
+ * 3. show the user a service-provided SUMMARY.
12
+ *
13
+ * Shape of a service's `extras` (camelCase here; Python services author the
14
+ * snake_case equivalent and the client transform converts it — see the Plaid
15
+ * manual, "Describing a service"):
16
+ *
17
+ * {
18
+ * schemaVersion: 1,
19
+ * tasks: ["tokenize"], // controlled vocab; REPLACES tok:/asr: id prefixes
20
+ * summary: "## markdown …", // rich human description
21
+ * parameters: [ // ordered; rendered into a form
22
+ * { key, label, type, description?, default?, required?,
23
+ * options?: [{value, label}], // enum / multiselect
24
+ * min?, max?, step?, // number
25
+ * placeholder?, multiline? } // string
26
+ * ]
27
+ * }
28
+ *
29
+ * A parameter's `key` is a string VALUE, so it passes over the wire verbatim;
30
+ * the UI sends `{ [param.key]: value }` in the request data. Declare each `key`
31
+ * in your service's own convention (Python: snake_case; JS: camelCase) and read
32
+ * it back under that same key — request data is recased symmetrically, so a
33
+ * snake_case key round-trips unchanged to a JS UI and back.
34
+ */
35
+
36
+ /**
37
+ * The controlled task vocabulary — the fixed integration-point goals.
38
+ *
39
+ * Whatever the task, a service should stamp everything it creates with the
40
+ * provenance convention (see ./provenance.js: stampInferred(serviceSource(id)))
41
+ * so UIs can distinguish machine output until a human verifies it, and should
42
+ * honor the write contract: never destroy human-made or verified material
43
+ * without an explicit `overwrite` parameter. See the manual, "Provenance".
44
+ */
45
+ export const TASKS = Object.freeze({
46
+ TOKENIZE: 'tokenize',
47
+ PARSE: 'parse',
48
+ TRANSCRIBE: 'transcribe',
49
+ /** Create vocab links for unlinked tokens. */
50
+ LINK_VOCAB: 'link-vocab',
51
+ });
52
+
53
+ /**
54
+ * Legacy id-prefix → task map, for services that have not yet migrated to a
55
+ * declared `tasks` array. Drop once all services advertise `tasks`.
56
+ */
57
+ const LEGACY_TASK_PREFIXES = Object.freeze({
58
+ [TASKS.TOKENIZE]: 'tok:',
59
+ [TASKS.TRANSCRIBE]: 'asr:',
60
+ });
61
+
62
+ /**
63
+ * Does `service` serve `task`? Prefers the declared `extras.tasks` array; falls
64
+ * back to the legacy id-prefix convention for un-migrated services.
65
+ * @param {{serviceId?: string, extras?: {tasks?: string[]}}} service
66
+ * @param {string} task one of TASKS
67
+ * @returns {boolean}
68
+ */
69
+ export function servesTask(service, task) {
70
+ const declared = service?.extras?.tasks;
71
+ if (Array.isArray(declared) && declared.length) {
72
+ return declared.includes(task);
73
+ }
74
+ const prefix = LEGACY_TASK_PREFIXES[task];
75
+ return !!prefix && typeof service?.serviceId === 'string' && service.serviceId.startsWith(prefix);
76
+ }
77
+
78
+ /**
79
+ * The discovered services that serve `task`.
80
+ * @param {Array} services result of client.messages.discoverServices()
81
+ * @param {string} task one of TASKS
82
+ * @returns {Array}
83
+ */
84
+ export function filterServicesByTask(services, task) {
85
+ return (services || []).filter((s) => servesTask(s, task));
86
+ }
87
+
88
+ /**
89
+ * The parameter schema a service declares (ordered array), or [].
90
+ * @param {{extras?: {parameters?: Array}}} service
91
+ * @returns {Array}
92
+ */
93
+ export function getParamSchema(service) {
94
+ const params = service?.extras?.parameters;
95
+ return Array.isArray(params) ? params : [];
96
+ }
97
+
98
+ /**
99
+ * A service's human summary: the rich `extras.summary`, else the short
100
+ * `description`, else ''.
101
+ * @param {{description?: string, extras?: {summary?: string}}} service
102
+ * @returns {string}
103
+ */
104
+ export function getServiceSummary(service) {
105
+ return service?.extras?.summary || service?.description || '';
106
+ }
107
+
108
+ /** Valid option values for an enum/multiselect param. */
109
+ function optionValues(param) {
110
+ return Array.isArray(param?.options) ? param.options.map((o) => o.value) : [];
111
+ }
112
+
113
+ /**
114
+ * A single parameter's default, honoring its declared `default` then falling
115
+ * back per type. For enum/multiselect the declared default is validated against
116
+ * `options` (an out-of-range declared default never escapes).
117
+ */
118
+ function defaultForParam(param) {
119
+ 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] ?? '';
123
+ }
124
+ if (param.type === 'multiselect') {
125
+ const arr = Array.isArray(param.default) ? param.default : [];
126
+ return opts.length ? arr.filter((x) => opts.includes(x)) : arr;
127
+ }
128
+ if (param.default !== undefined && param.default !== null) return param.default;
129
+ 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 '';
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Default values keyed by param key — the initial form state.
139
+ * @param {Array} schema getParamSchema(service)
140
+ * @returns {Object} { [key]: defaultValue }
141
+ */
142
+ export function buildDefaultValues(schema) {
143
+ const out = {};
144
+ for (const param of schema || []) {
145
+ if (!param || !param.key) continue;
146
+ out[param.key] = defaultForParam(param);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Coerce/validate raw form values against the schema. Returns the cleaned
153
+ * values (keyed by param key, ready to merge into the request payload) plus any
154
+ * validation errors keyed by param key. Unknown keys in `raw` are dropped.
155
+ * @param {Array} schema getParamSchema(service)
156
+ * @param {Object} raw current form values
157
+ * @returns {{values: Object, errors: Object}}
158
+ */
159
+ export function coerceParamValues(schema, raw) {
160
+ const values = {};
161
+ const errors = {};
162
+ const src = raw || {};
163
+ for (const param of schema || []) {
164
+ if (!param || !param.key) continue;
165
+ const k = param.key;
166
+ let v = src[k];
167
+ if (v === undefined) v = defaultForParam(param);
168
+
169
+ switch (param.type) {
170
+ case 'number': {
171
+ // Blank / nullish counts as "missing" → the param's default (matches the
172
+ // Python client, where float('') raises and falls back to the default).
173
+ let n;
174
+ if (v === '' || v == null || (typeof v === 'string' && v.trim() === '')) {
175
+ n = defaultForParam(param);
176
+ } else {
177
+ n = typeof v === 'number' ? v : Number(v);
178
+ if (Number.isNaN(n)) n = defaultForParam(param);
179
+ }
180
+ if (typeof param.min === 'number') n = Math.max(param.min, n);
181
+ if (typeof param.max === 'number') n = Math.min(param.max, n);
182
+ v = n;
183
+ break;
184
+ }
185
+ case 'boolean':
186
+ v = v === true || v === 'true';
187
+ break;
188
+ case 'enum': {
189
+ const opts = optionValues(param);
190
+ if (opts.length && !opts.includes(v)) v = defaultForParam(param);
191
+ break;
192
+ }
193
+ case 'multiselect': {
194
+ const opts = optionValues(param);
195
+ const arr = Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]);
196
+ v = opts.length ? arr.filter((x) => opts.includes(x)) : arr;
197
+ break;
198
+ }
199
+ case 'string':
200
+ default:
201
+ v = v == null ? '' : String(v);
202
+ break;
203
+ }
204
+
205
+ if (param.required) {
206
+ const empty = v === '' || v == null || (Array.isArray(v) && v.length === 0);
207
+ if (empty) errors[k] = `${param.label || k} is required`;
208
+ }
209
+ values[k] = v;
210
+ }
211
+ return { values, errors };
212
+ }
package/src/services.js CHANGED
@@ -11,18 +11,34 @@
11
11
  import { transformRequest, transformResponse } from './transforms.js';
12
12
 
13
13
  /**
14
- * Discover the services currently connected to a project — a synchronous GET.
15
- * `timeout` is accepted for back-compat and ignored.
14
+ * Discover the services seen on a project — a synchronous GET. Returns every
15
+ * service ever registered on the project: currently connected ones carry
16
+ * `online: true`; previously-seen offline ones carry `online: false` plus a
17
+ * `lastSeenAt` stamp. Callers that need a service they can actually submit
18
+ * work to should filter on `online`.
16
19
  *
17
20
  * @param {Object} client - PlaidClient instance
18
21
  * @param {string} projectId - Project UUID
19
- * @param {number} [timeout] - Ignored
20
- * @returns {Promise<Array>} [{serviceId, serviceName, description, extras}]
22
+ * @returns {Promise<Array>} [{serviceId, serviceName, description, extras, online, lastSeenAt}]
21
23
  */
22
- export function discoverServices(client, projectId, timeout) {
24
+ export function discoverServices(client, projectId) {
23
25
  return client._request('GET', `/api/v1/projects/${projectId}/services`);
24
26
  }
25
27
 
28
+ /**
29
+ * Forget a previously-seen (offline) service: removes its row from the
30
+ * project's persistent registry. Maintainer-only; 409 if the service is
31
+ * currently connected (it would just re-register).
32
+ *
33
+ * @param {Object} client - PlaidClient instance
34
+ * @param {string} projectId - Project UUID
35
+ * @param {string} serviceId - Service ID to forget
36
+ * @returns {Promise<void>}
37
+ */
38
+ export function discardService(client, projectId, serviceId) {
39
+ return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`);
40
+ }
41
+
26
42
  /**
27
43
  * Register a service and handle incoming work requests.
28
44
  *
package/src/sse.js CHANGED
@@ -75,6 +75,13 @@ export function createSSEConnection(client, projectId, onEvent, path) {
75
75
  });
76
76
 
77
77
  if (!response.ok) {
78
+ if (response.status === 409 && streamPath.includes('/services/')) {
79
+ // Service channel rejected: another live instance holds this
80
+ // service-id. The serve() reconnect timer will retry — once the
81
+ // other instance is gone (or its dead channel is reaped) the retry
82
+ // takes over.
83
+ console.warn('Service registration rejected (409): another instance of this service is already connected; will retry');
84
+ }
78
85
  throw new Error(`HTTP ${response.status} ${response.statusText}`);
79
86
  }
80
87
 
package/src/transforms.js CHANGED
@@ -25,7 +25,7 @@ export function transformKeyFromCamel(key) {
25
25
  // are never re-cased or namespace-stripped — a label like `case-marker` used as
26
26
  // a map key survives intact. Everything else is API envelope and gets the
27
27
  // usual case conversion.
28
- const OPAQUE_KEYS = new Set(['metadata', 'config']);
28
+ const OPAQUE_KEYS = new Set(['metadata', 'config', 'bindings']);
29
29
 
30
30
  /**
31
31
  * Recursively transform request object keys from camelCase to kebab-case.