@oxy-hq/sdk 2.5.0 → 2.8.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/README.md +55 -5
- package/dist/index.cjs +677 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +708 -16
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +708 -16
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +657 -10
- package/dist/index.mjs.map +1 -1
- package/dist/{react-BeweGSYF.cjs → react-BFFCK4VM.cjs} +34 -29
- package/dist/react-BFFCK4VM.cjs.map +1 -0
- package/dist/{react-BnpR8VRJ.d.mts → react-kHG5gkd-.d.cts} +22 -17
- package/dist/react-kHG5gkd-.d.cts.map +1 -0
- package/dist/{react-BnpR8VRJ.d.cts → react-kHG5gkd-.d.mts} +22 -17
- package/dist/react-kHG5gkd-.d.mts.map +1 -0
- package/dist/{react-CVdWXcu8.mjs → react-sT7E4CbA.mjs} +29 -24
- package/dist/react-sT7E4CbA.mjs.map +1 -0
- package/dist/shell.cjs +6 -5
- package/dist/shell.cjs.map +1 -1
- package/dist/shell.css +28 -6
- package/dist/shell.d.cts +3 -3
- package/dist/shell.d.cts.map +1 -1
- package/dist/shell.d.mts +3 -3
- package/dist/shell.d.mts.map +1 -1
- package/dist/shell.mjs +6 -5
- package/dist/shell.mjs.map +1 -1
- package/package.json +10 -10
- package/dist/react-BeweGSYF.cjs.map +0 -1
- package/dist/react-BnpR8VRJ.d.cts.map +0 -1
- package/dist/react-BnpR8VRJ.d.mts.map +0 -1
- package/dist/react-CVdWXcu8.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @oxy/sdk - TypeScript SDK for Oxy data platform
|
|
2
|
-
import { _ as
|
|
2
|
+
import { _ as interpretCustomAppError, a as useFunction, c as useQuery, d as useTrackEvent, f as _resetCustomAppManifestCacheForTest, g as apiErrorFromResponse, h as OxyApiError, i as useAgentRun, l as useResolvedManifest, m as readInjectedAppConfig, n as OxyAppProvider, o as useOxyApp, p as loadCustomAppManifest, r as OxyChat, s as useProcedureRun, t as OxyAnswer, u as useSemanticQuery, v as getOxyAppLogger, y as setOxyAppLogger } from "./react-sT7E4CbA.mjs";
|
|
3
|
+
import * as React from "react";
|
|
3
4
|
|
|
4
5
|
//#region src/anomalies.ts
|
|
5
6
|
/**
|
|
@@ -49,11 +50,20 @@ var AnomaliesClient = class {
|
|
|
49
50
|
* workspace, runs the detector, and upserts matching rows into the
|
|
50
51
|
* inbox. Returns counts of scanned / failed / persisted.
|
|
51
52
|
*
|
|
53
|
+
* Long-running: the server waits up to 55 s, then returns
|
|
54
|
+
* `pending: true` with zeroed counts while the scan finishes in the
|
|
55
|
+
* background. Always check `pending` before treating `0` as "nothing
|
|
56
|
+
* found", and refetch with {@link list} shortly after.
|
|
57
|
+
*
|
|
52
58
|
* @example
|
|
53
59
|
* ```typescript
|
|
54
60
|
* // Scan against a known-good reference date (matches the seed dataset)
|
|
55
61
|
* const result = await client.anomalies.scan({ as_of: "2025-12-15" });
|
|
56
|
-
*
|
|
62
|
+
* if (result.pending) {
|
|
63
|
+
* console.log("scan still running — refetch shortly");
|
|
64
|
+
* } else {
|
|
65
|
+
* console.log(`${result.anomalies_persisted} anomalies detected`);
|
|
66
|
+
* }
|
|
57
67
|
* ```
|
|
58
68
|
*/
|
|
59
69
|
async scan(options = {}) {
|
|
@@ -73,23 +83,119 @@ var AnomaliesClient = class {
|
|
|
73
83
|
}
|
|
74
84
|
/**
|
|
75
85
|
* Run the metric-tree `explain` for an anomaly and cache the result on
|
|
76
|
-
* the row. Subsequent calls return the cached `ExplainResult` instantly
|
|
86
|
+
* the row. Subsequent calls return the cached `ExplainResult` instantly;
|
|
87
|
+
* pass `{ refresh: true }` to bust the cache and recompute.
|
|
88
|
+
*
|
|
89
|
+
* The uncached path runs a 20-30 s recursive driver search — budget for it
|
|
90
|
+
* (or read `explain_cache` off the row from {@link list} when it's already
|
|
91
|
+
* populated).
|
|
77
92
|
*/
|
|
78
|
-
async explain(anomalyId) {
|
|
79
|
-
const
|
|
80
|
-
|
|
93
|
+
async explain(anomalyId, options = {}) {
|
|
94
|
+
const extra = {};
|
|
95
|
+
if (options.refresh) extra.refresh = "true";
|
|
96
|
+
return this.request(this.path(`/${encodeURIComponent(anomalyId)}/explain${this.buildQuery(extra)}`), { method: "POST" });
|
|
81
97
|
}
|
|
82
98
|
};
|
|
83
99
|
|
|
84
100
|
//#endregion
|
|
85
|
-
//#region src/
|
|
101
|
+
//#region src/custom-app/base64.ts
|
|
102
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
103
|
+
/** Reverse lookup; 255 marks "not a base64 character". */
|
|
104
|
+
const B64R = /* @__PURE__ */ (() => {
|
|
105
|
+
const t = (/* @__PURE__ */ new Uint8Array(256)).fill(255);
|
|
106
|
+
for (let i = 0; i < 64; i++) t[B64.charCodeAt(i)] = i;
|
|
107
|
+
return t;
|
|
108
|
+
})();
|
|
109
|
+
/**
|
|
110
|
+
* Chunk size for building output in segments. Byte-at-a-time `+=` allocates a
|
|
111
|
+
* rope node per byte, and `String.fromCharCode.apply` blows the argument limit
|
|
112
|
+
* on large inputs; 8k avoids both.
|
|
113
|
+
*/
|
|
114
|
+
const CHUNK = 8192;
|
|
115
|
+
function asBytes(input) {
|
|
116
|
+
if (input instanceof Uint8Array) return input;
|
|
117
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
118
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Encode bytes as standard (padded) base64.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* const pdf = new Uint8Array(await renderReport());
|
|
125
|
+
* await ctx.email.send({
|
|
126
|
+
* to: ctx.user.email,
|
|
127
|
+
* subject: "Report",
|
|
128
|
+
* text: "attached",
|
|
129
|
+
* attachments: [{ filename: "report.pdf", content: bytesToBase64(pdf) }]
|
|
130
|
+
* });
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* For **text** you generated, skip this entirely and pass the string with
|
|
134
|
+
* `encoding: "utf8"` — it needs no encoder and stays byte-exact for non-ASCII.
|
|
135
|
+
*/
|
|
136
|
+
function bytesToBase64(input) {
|
|
137
|
+
const bytes = asBytes(input);
|
|
138
|
+
const parts = [];
|
|
139
|
+
let buf = "";
|
|
140
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
141
|
+
const b0 = bytes[i];
|
|
142
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
143
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
144
|
+
const n = b0 << 16 | b1 << 8 | b2;
|
|
145
|
+
buf += B64[n >> 18 & 63] + B64[n >> 12 & 63] + (i + 1 < bytes.length ? B64[n >> 6 & 63] : "=") + (i + 2 < bytes.length ? B64[n & 63] : "=");
|
|
146
|
+
if (buf.length >= CHUNK) {
|
|
147
|
+
parts.push(buf);
|
|
148
|
+
buf = "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
parts.push(buf);
|
|
152
|
+
return parts.join("");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Decode standard base64 to bytes — e.g. the body from
|
|
156
|
+
* `ctx.storage.get(key, { encoding: "base64" })`.
|
|
157
|
+
*
|
|
158
|
+
* Throws on malformed input rather than returning a short buffer: a truncated
|
|
159
|
+
* decode that reports success is a corrupt file nobody notices.
|
|
160
|
+
*/
|
|
161
|
+
function base64ToBytes(base64) {
|
|
162
|
+
let s = String(base64).replace(/[ \t\n\f\r]/g, "");
|
|
163
|
+
if (s.length % 4 === 0) {
|
|
164
|
+
let pad = 0;
|
|
165
|
+
while (pad < 2 && s.charCodeAt(s.length - 1) === 61) {
|
|
166
|
+
s = s.slice(0, -1);
|
|
167
|
+
pad++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (s.indexOf("=") >= 0) throw new TypeError("base64ToBytes: '=' may only appear as trailing padding");
|
|
171
|
+
if (s.length % 4 === 1) throw new TypeError("base64ToBytes: invalid base64 length");
|
|
172
|
+
const out = new Uint8Array(s.length * 3 >> 2);
|
|
173
|
+
let o = 0;
|
|
174
|
+
let buf = 0;
|
|
175
|
+
let bits = 0;
|
|
176
|
+
for (let i = 0; i < s.length; i++) {
|
|
177
|
+
const code = s.charCodeAt(i);
|
|
178
|
+
const v = code < 256 ? B64R[code] : 255;
|
|
179
|
+
if (v === 255) throw new TypeError(`base64ToBytes: invalid base64 character '${s[i]}'`);
|
|
180
|
+
buf = buf << 6 | v;
|
|
181
|
+
bits += 6;
|
|
182
|
+
if (bits >= 8) {
|
|
183
|
+
bits -= 8;
|
|
184
|
+
out[o++] = buf >> bits & 255;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out.subarray(0, o);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/custom-app/debug.ts
|
|
86
192
|
/**
|
|
87
193
|
* Fetch the server-side diagnostic snapshot for this bundle. Pair with
|
|
88
|
-
* `
|
|
194
|
+
* `loadCustomAppManifest()` — pass its result here. Logs the
|
|
89
195
|
* snapshot through the SDK logger so it appears in the bundle's
|
|
90
196
|
* console at info level.
|
|
91
197
|
*/
|
|
92
|
-
async function
|
|
198
|
+
async function getCustomAppDebug(resolved) {
|
|
93
199
|
const log = getOxyAppLogger();
|
|
94
200
|
const { apiBaseUrl, orgSlug, appSlug } = resolved;
|
|
95
201
|
const url = `${apiBaseUrl}/api/customer-apps/${encodeURIComponent(orgSlug)}/${encodeURIComponent(appSlug)}/debug`;
|
|
@@ -104,6 +210,547 @@ async function getCustomerAppDebug(resolved) {
|
|
|
104
210
|
return snapshot;
|
|
105
211
|
}
|
|
106
212
|
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/custom-app/metric-tree-fetch.ts
|
|
215
|
+
/** Base path for the metric-tree endpoints of `projectId`. */
|
|
216
|
+
function metricTreePath(projectId) {
|
|
217
|
+
return `/api/projects/${projectId}/semantic/metric-tree`;
|
|
218
|
+
}
|
|
219
|
+
/** GET `url`, decoding JSON or throwing a typed {@link OxyApiError}. */
|
|
220
|
+
async function getJson(fetcher, url, signal) {
|
|
221
|
+
const resp = await fetcher(url, {
|
|
222
|
+
method: "GET",
|
|
223
|
+
signal
|
|
224
|
+
});
|
|
225
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
226
|
+
return await resp.json();
|
|
227
|
+
}
|
|
228
|
+
/** POST `body` (tagged `v: 1`) to `url`, decoding JSON or throwing. */
|
|
229
|
+
async function postJson(fetcher, url, body, signal) {
|
|
230
|
+
const resp = await fetcher(url, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "content-type": "application/json" },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
v: 1,
|
|
235
|
+
...body
|
|
236
|
+
}),
|
|
237
|
+
signal
|
|
238
|
+
});
|
|
239
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
240
|
+
return await resp.json();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/custom-app/metric-tree-hooks.tsx
|
|
245
|
+
/**
|
|
246
|
+
* Internal engine shared by every metric-tree hook. Runs `run(signal)`
|
|
247
|
+
* whenever `key` changes (and on `refetch`), tracks loading/error, and
|
|
248
|
+
* cancels in-flight work on unmount or input change.
|
|
249
|
+
*
|
|
250
|
+
* `key` is the deep-compare fingerprint of the request; a `null` key
|
|
251
|
+
* means "no request yet" and leaves the hook idle without firing.
|
|
252
|
+
*/
|
|
253
|
+
function useMetricTreeEndpoint(key, run, enabled) {
|
|
254
|
+
const [data, setData] = React.useState(null);
|
|
255
|
+
const [loading, setLoading] = React.useState(enabled && key !== null);
|
|
256
|
+
const [error, setError] = React.useState(null);
|
|
257
|
+
const [_nonce, setNonce] = React.useState(0);
|
|
258
|
+
const runRef = React.useRef(run);
|
|
259
|
+
runRef.current = run;
|
|
260
|
+
React.useEffect(() => {
|
|
261
|
+
if (!enabled || key === null) {
|
|
262
|
+
setLoading(false);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const ctrl = new AbortController();
|
|
266
|
+
let cancelled = false;
|
|
267
|
+
setLoading(true);
|
|
268
|
+
setError(null);
|
|
269
|
+
runRef.current(ctrl.signal).then((result) => {
|
|
270
|
+
if (cancelled) return;
|
|
271
|
+
setData(result);
|
|
272
|
+
setLoading(false);
|
|
273
|
+
}).catch((err) => {
|
|
274
|
+
if (cancelled) return;
|
|
275
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
276
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
277
|
+
setLoading(false);
|
|
278
|
+
});
|
|
279
|
+
return () => {
|
|
280
|
+
cancelled = true;
|
|
281
|
+
ctrl.abort();
|
|
282
|
+
};
|
|
283
|
+
}, [key, enabled]);
|
|
284
|
+
return {
|
|
285
|
+
data,
|
|
286
|
+
loading,
|
|
287
|
+
error,
|
|
288
|
+
refetch: React.useCallback(() => setNonce((n) => n + 1), [])
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The project's metric tree — measures (nodes) and their component /
|
|
293
|
+
* driver relationships (edges) — or the subtree rooted at `opts.root`.
|
|
294
|
+
* The structural backbone every other metric-tree analysis reads against.
|
|
295
|
+
*/
|
|
296
|
+
function useMetricTree(opts = {}) {
|
|
297
|
+
const { projectId, fetcher } = useOxyApp();
|
|
298
|
+
const enabled = opts.enabled !== false;
|
|
299
|
+
const root = opts.root;
|
|
300
|
+
return useMetricTreeEndpoint(projectId ? JSON.stringify({
|
|
301
|
+
projectId,
|
|
302
|
+
root
|
|
303
|
+
}) : null, (signal) => {
|
|
304
|
+
const qs = root ? `?root=${encodeURIComponent(root)}` : "";
|
|
305
|
+
return getJson(fetcher, `${metricTreePath(projectId)}${qs}`, signal);
|
|
306
|
+
}, enabled);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Ranked drivers of `measureId`, by influence — the "what moves this
|
|
310
|
+
* measure" question. Pass `null` to stay idle until a measure is chosen.
|
|
311
|
+
*/
|
|
312
|
+
function useSensitivity(measureId, opts = {}) {
|
|
313
|
+
const { projectId, fetcher } = useOxyApp();
|
|
314
|
+
const enabled = opts.enabled !== false;
|
|
315
|
+
return useMetricTreeEndpoint(projectId && measureId ? JSON.stringify({
|
|
316
|
+
projectId,
|
|
317
|
+
measureId
|
|
318
|
+
}) : null, (signal) => {
|
|
319
|
+
const path = `${metricTreePath(projectId)}/${encodeURIComponent(measureId)}/sensitivity`;
|
|
320
|
+
return getJson(fetcher, path, signal);
|
|
321
|
+
}, enabled);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Propagate hypothetical `(measure, delta)` changes upward through the
|
|
325
|
+
* tree and return the estimated impact on every downstream measure — a
|
|
326
|
+
* pure metric-tree walk, no warehouse query. Pass `null` to stay idle.
|
|
327
|
+
*/
|
|
328
|
+
function usePredict(changes, opts = {}) {
|
|
329
|
+
const { projectId, fetcher } = useOxyApp();
|
|
330
|
+
const enabled = opts.enabled !== false;
|
|
331
|
+
return useMetricTreeEndpoint(projectId && changes ? JSON.stringify({
|
|
332
|
+
projectId,
|
|
333
|
+
changes
|
|
334
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/predict`, { changes }, signal), enabled);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Period-over-period root-cause decomposition: recursively splits the
|
|
338
|
+
* target measure by components and dimensions until the move concentrates.
|
|
339
|
+
* This is the heavy one — it can fire many warehouse queries and the
|
|
340
|
+
* server caps it at 45s. Pass `null` to defer until periods are chosen.
|
|
341
|
+
*/
|
|
342
|
+
function useExplain(request, opts = {}) {
|
|
343
|
+
const { projectId, fetcher } = useOxyApp();
|
|
344
|
+
const enabled = opts.enabled !== false;
|
|
345
|
+
return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
|
|
346
|
+
projectId,
|
|
347
|
+
request
|
|
348
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/explain`, request, signal), enabled);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Single-period distribution of a measure — an {@link ExplainResult}
|
|
352
|
+
* against an auto-derived immediately-prior baseline. Same renderers as
|
|
353
|
+
* `useExplain`; ignore the delta fields for a pure distribution view.
|
|
354
|
+
*/
|
|
355
|
+
function useDistribution(request, opts = {}) {
|
|
356
|
+
const { projectId, fetcher } = useOxyApp();
|
|
357
|
+
const enabled = opts.enabled !== false;
|
|
358
|
+
return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
|
|
359
|
+
projectId,
|
|
360
|
+
request
|
|
361
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/distribution`, request, signal), enabled);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Segment opportunity sizing for a measure over a period: finds
|
|
365
|
+
* underperforming segments and sizes the addressable upside of closing
|
|
366
|
+
* each rate gap against a benchmark peer. Pass `null` to stay idle until
|
|
367
|
+
* a target + period are chosen.
|
|
368
|
+
*/
|
|
369
|
+
function useOpportunity(request, opts = {}) {
|
|
370
|
+
const { projectId, fetcher } = useOxyApp();
|
|
371
|
+
const enabled = opts.enabled !== false;
|
|
372
|
+
return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
|
|
373
|
+
projectId,
|
|
374
|
+
request
|
|
375
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/opportunity`, request, signal), enabled);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The queryable time dimensions per view (`view.dim` ids) — what a
|
|
379
|
+
* bundle offers as the period axis for `explain` / `opportunity` /
|
|
380
|
+
* `distribution` instead of hardcoding a curated map.
|
|
381
|
+
*/
|
|
382
|
+
function useTimeDimensions(opts = {}) {
|
|
383
|
+
const { projectId, fetcher } = useOxyApp();
|
|
384
|
+
const enabled = opts.enabled !== false;
|
|
385
|
+
return useMetricTreeEndpoint(projectId ? JSON.stringify({
|
|
386
|
+
projectId,
|
|
387
|
+
kind: "time-dimensions"
|
|
388
|
+
}) : null, (signal) => getJson(fetcher, `${metricTreePath(projectId)}/time-dimensions`, signal), enabled);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/custom-app/sse.ts
|
|
393
|
+
/**
|
|
394
|
+
* Read a `text/event-stream` response, invoking `onEvent` with each parsed
|
|
395
|
+
* JSON frame. Frames that fail to parse are skipped (a malformed frame must
|
|
396
|
+
* not tear down the whole stream). Resolves when the body closes.
|
|
397
|
+
*/
|
|
398
|
+
async function readJsonSseStream(resp, onEvent) {
|
|
399
|
+
const reader = resp.body?.getReader();
|
|
400
|
+
if (!reader) throw new Error("SSE response has no body stream");
|
|
401
|
+
const decoder = new TextDecoder();
|
|
402
|
+
let buffer = "";
|
|
403
|
+
for (;;) {
|
|
404
|
+
const { done, value } = await reader.read();
|
|
405
|
+
if (done) break;
|
|
406
|
+
buffer += decoder.decode(value, { stream: true });
|
|
407
|
+
let sep;
|
|
408
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
409
|
+
const frame = buffer.slice(0, sep);
|
|
410
|
+
buffer = buffer.slice(sep + 2);
|
|
411
|
+
let data = "";
|
|
412
|
+
for (const line of frame.split("\n")) if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
413
|
+
if (!data) continue;
|
|
414
|
+
let parsed;
|
|
415
|
+
try {
|
|
416
|
+
parsed = JSON.parse(data);
|
|
417
|
+
} catch {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
onEvent(parsed);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/custom-app/world-model-hooks.tsx
|
|
427
|
+
/** Base path for the world-model endpoints of the active project. */
|
|
428
|
+
function worldModelPath(projectId) {
|
|
429
|
+
return `/api/projects/${projectId}/semantic/world-model`;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The world-model graph — entities (nodes), their measures/dimensions, and
|
|
433
|
+
* how measures promote across the entity hierarchy (edges). Applies the
|
|
434
|
+
* project's `.world-model.yml` display config server-side.
|
|
435
|
+
*
|
|
436
|
+
* @remarks
|
|
437
|
+
* This returns the raw semantic-layer entity graph. For the higher-level
|
|
438
|
+
* node-paradigm interface (`world.metric(id)` speaking `expand` / `explain` /
|
|
439
|
+
* `size`), use {@link useWorldModel} from `./world-node` instead.
|
|
440
|
+
*/
|
|
441
|
+
function useWorldModelGraph(opts = {}) {
|
|
442
|
+
const { projectId, fetcher } = useOxyApp();
|
|
443
|
+
const enabled = opts.enabled !== false;
|
|
444
|
+
const [data, setData] = React.useState(null);
|
|
445
|
+
const [loading, setLoading] = React.useState(enabled && !!projectId);
|
|
446
|
+
const [error, setError] = React.useState(null);
|
|
447
|
+
const [_nonce, setNonce] = React.useState(0);
|
|
448
|
+
React.useEffect(() => {
|
|
449
|
+
if (!enabled || !projectId) {
|
|
450
|
+
setLoading(false);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const ctrl = new AbortController();
|
|
454
|
+
let cancelled = false;
|
|
455
|
+
setLoading(true);
|
|
456
|
+
setError(null);
|
|
457
|
+
fetcher(worldModelPath(projectId), {
|
|
458
|
+
method: "GET",
|
|
459
|
+
signal: ctrl.signal
|
|
460
|
+
}).then(async (resp) => {
|
|
461
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
462
|
+
return await resp.json();
|
|
463
|
+
}).then((result) => {
|
|
464
|
+
if (cancelled) return;
|
|
465
|
+
setData(result);
|
|
466
|
+
setLoading(false);
|
|
467
|
+
}).catch((err) => {
|
|
468
|
+
if (cancelled) return;
|
|
469
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
470
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
471
|
+
setLoading(false);
|
|
472
|
+
});
|
|
473
|
+
return () => {
|
|
474
|
+
cancelled = true;
|
|
475
|
+
ctrl.abort();
|
|
476
|
+
};
|
|
477
|
+
}, [
|
|
478
|
+
enabled,
|
|
479
|
+
projectId,
|
|
480
|
+
fetcher
|
|
481
|
+
]);
|
|
482
|
+
return {
|
|
483
|
+
data,
|
|
484
|
+
loading,
|
|
485
|
+
error,
|
|
486
|
+
refetch: React.useCallback(() => setNonce((n) => n + 1), [])
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* List the instances (rows) of `entityId` — a bounded, searchable picker
|
|
491
|
+
* over the entity's primary keys + display label. Pass `null` for `entityId`
|
|
492
|
+
* to stay idle until an entity is chosen.
|
|
493
|
+
*/
|
|
494
|
+
function useWorldModelInstances(entityId, opts = {}) {
|
|
495
|
+
const { projectId, fetcher } = useOxyApp();
|
|
496
|
+
const enabled = opts.enabled !== false;
|
|
497
|
+
const { search, limit } = opts;
|
|
498
|
+
const [data, setData] = React.useState(null);
|
|
499
|
+
const [loading, setLoading] = React.useState(enabled && !!projectId && !!entityId);
|
|
500
|
+
const [error, setError] = React.useState(null);
|
|
501
|
+
const [_nonce, setNonce] = React.useState(0);
|
|
502
|
+
React.useEffect(() => {
|
|
503
|
+
if (!enabled || !projectId || !entityId) {
|
|
504
|
+
setLoading(false);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const ctrl = new AbortController();
|
|
508
|
+
let cancelled = false;
|
|
509
|
+
setLoading(true);
|
|
510
|
+
setError(null);
|
|
511
|
+
const params = new URLSearchParams({ entity: entityId });
|
|
512
|
+
if (search) params.set("search", search);
|
|
513
|
+
if (limit != null) params.set("limit", String(limit));
|
|
514
|
+
fetcher(`${worldModelPath(projectId)}/instances?${params}`, {
|
|
515
|
+
method: "GET",
|
|
516
|
+
signal: ctrl.signal
|
|
517
|
+
}).then(async (resp) => {
|
|
518
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
519
|
+
return await resp.json();
|
|
520
|
+
}).then((result) => {
|
|
521
|
+
if (cancelled) return;
|
|
522
|
+
setData(result);
|
|
523
|
+
setLoading(false);
|
|
524
|
+
}).catch((err) => {
|
|
525
|
+
if (cancelled) return;
|
|
526
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
527
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
528
|
+
setLoading(false);
|
|
529
|
+
});
|
|
530
|
+
return () => {
|
|
531
|
+
cancelled = true;
|
|
532
|
+
ctrl.abort();
|
|
533
|
+
};
|
|
534
|
+
}, [
|
|
535
|
+
enabled,
|
|
536
|
+
projectId,
|
|
537
|
+
entityId,
|
|
538
|
+
search,
|
|
539
|
+
limit,
|
|
540
|
+
fetcher
|
|
541
|
+
]);
|
|
542
|
+
return {
|
|
543
|
+
data,
|
|
544
|
+
loading,
|
|
545
|
+
error,
|
|
546
|
+
refetch: React.useCallback(() => setNonce((n) => n + 1), [])
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/** Fold one measure-breakdown SSE frame into the accumulated graph. */
|
|
550
|
+
function foldBreakdown(prev, ev) {
|
|
551
|
+
switch (ev.kind) {
|
|
552
|
+
case "init": return {
|
|
553
|
+
root: ev.root,
|
|
554
|
+
nodes: ev.nodes.map((n) => ({
|
|
555
|
+
...n,
|
|
556
|
+
value: null,
|
|
557
|
+
unvalued_reason: null
|
|
558
|
+
})),
|
|
559
|
+
edges: ev.edges
|
|
560
|
+
};
|
|
561
|
+
case "value":
|
|
562
|
+
if (!prev) return prev;
|
|
563
|
+
return {
|
|
564
|
+
...prev,
|
|
565
|
+
nodes: prev.nodes.map((n) => n.id === ev.node_id ? {
|
|
566
|
+
...n,
|
|
567
|
+
value: ev.value,
|
|
568
|
+
unvalued_reason: ev.unvalued_reason
|
|
569
|
+
} : n)
|
|
570
|
+
};
|
|
571
|
+
default: return prev;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Stream the driver-tree breakdown of one instance's measure — the metric
|
|
576
|
+
* decomposition (add/sub/mul/div component graph) with each node's value
|
|
577
|
+
* filling in as it resolves. This is the per-instance RCA view. Pass `null`
|
|
578
|
+
* for `measure` to stay idle.
|
|
579
|
+
*/
|
|
580
|
+
function useMeasureBreakdown(entityId, keyValue, measure) {
|
|
581
|
+
const { projectId, fetcher } = useOxyApp();
|
|
582
|
+
const [breakdown, setBreakdown] = React.useState(null);
|
|
583
|
+
const [loading, setLoading] = React.useState(false);
|
|
584
|
+
const [done, setDone] = React.useState(false);
|
|
585
|
+
const [error, setError] = React.useState(null);
|
|
586
|
+
React.useEffect(() => {
|
|
587
|
+
if (!projectId || !entityId || !keyValue || !measure) {
|
|
588
|
+
setLoading(false);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const ctrl = new AbortController();
|
|
592
|
+
let cancelled = false;
|
|
593
|
+
setBreakdown(null);
|
|
594
|
+
setLoading(true);
|
|
595
|
+
setDone(false);
|
|
596
|
+
setError(null);
|
|
597
|
+
const params = new URLSearchParams({
|
|
598
|
+
entity: entityId,
|
|
599
|
+
key: keyValue,
|
|
600
|
+
measure
|
|
601
|
+
});
|
|
602
|
+
fetcher(`${worldModelPath(projectId)}/measure-breakdown?${params}`, {
|
|
603
|
+
method: "GET",
|
|
604
|
+
signal: ctrl.signal
|
|
605
|
+
}).then(async (resp) => {
|
|
606
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
607
|
+
await readJsonSseStream(resp, (ev) => {
|
|
608
|
+
if (cancelled) return;
|
|
609
|
+
if (ev.kind === "done") {
|
|
610
|
+
setDone(true);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
setBreakdown((prev) => foldBreakdown(prev, ev));
|
|
614
|
+
});
|
|
615
|
+
if (!cancelled) setLoading(false);
|
|
616
|
+
}).catch((err) => {
|
|
617
|
+
if (cancelled) return;
|
|
618
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
619
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
620
|
+
setLoading(false);
|
|
621
|
+
});
|
|
622
|
+
return () => {
|
|
623
|
+
cancelled = true;
|
|
624
|
+
ctrl.abort();
|
|
625
|
+
};
|
|
626
|
+
}, [
|
|
627
|
+
projectId,
|
|
628
|
+
entityId,
|
|
629
|
+
keyValue,
|
|
630
|
+
measure,
|
|
631
|
+
fetcher
|
|
632
|
+
]);
|
|
633
|
+
return {
|
|
634
|
+
breakdown,
|
|
635
|
+
loading,
|
|
636
|
+
done,
|
|
637
|
+
error
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/custom-app/world-node.tsx
|
|
643
|
+
/**
|
|
644
|
+
* Thrown by the value verbs (`explain` / `size`) when called on a handle that
|
|
645
|
+
* has been `drill`ed. The metric-tree backend cannot yet scope these analyses
|
|
646
|
+
* to a segment, so failing loud beats returning population numbers for a
|
|
647
|
+
* question that asked about one segment.
|
|
648
|
+
*/
|
|
649
|
+
var WorldModelScopeUnsupportedError = class extends Error {
|
|
650
|
+
constructor(verb, scope) {
|
|
651
|
+
super(`${verb} on a drilled (scoped) node is not yet supported by the backend (scope: ${JSON.stringify(scope)}). Call ${verb} on the un-drilled node for population-level analysis.`);
|
|
652
|
+
this.code = "world_model_scope_unsupported";
|
|
653
|
+
this.name = "WorldModelScopeUnsupportedError";
|
|
654
|
+
this.scope = scope;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
/**
|
|
658
|
+
* Build a {@link WorldModelApi} over a project id and fetcher. Framework-
|
|
659
|
+
* agnostic — `useWorldModel()` wraps this for React, but it is directly
|
|
660
|
+
* unit-testable with a mock fetcher.
|
|
661
|
+
*/
|
|
662
|
+
function createWorldModel(projectId, fetcher) {
|
|
663
|
+
const base = () => {
|
|
664
|
+
if (!projectId) throw new Error("World Model unavailable: no active project (are you inside <OxyAppProvider>?)");
|
|
665
|
+
return metricTreePath(projectId);
|
|
666
|
+
};
|
|
667
|
+
const tree = (root, signal) => {
|
|
668
|
+
const qs = root ? `?root=${encodeURIComponent(root)}` : "";
|
|
669
|
+
return getJson(fetcher, `${base()}${qs}`, signal);
|
|
670
|
+
};
|
|
671
|
+
const makeHandle = (id, scope) => {
|
|
672
|
+
const scoped = Object.keys(scope).length > 0;
|
|
673
|
+
return {
|
|
674
|
+
id,
|
|
675
|
+
scope,
|
|
676
|
+
async node(signal) {
|
|
677
|
+
const found = (await tree(id, signal)).nodes.find((n) => n.id === id);
|
|
678
|
+
if (!found) throw new Error(`measure '${id}' not found in the metric tree`);
|
|
679
|
+
return found;
|
|
680
|
+
},
|
|
681
|
+
async expand(signal) {
|
|
682
|
+
const t = await tree(id, signal);
|
|
683
|
+
const byId = new Map(t.nodes.map((n) => [n.id, n]));
|
|
684
|
+
const children = [];
|
|
685
|
+
for (const edge of t.edges) {
|
|
686
|
+
if (edge.from !== id) continue;
|
|
687
|
+
const childNode = byId.get(edge.to);
|
|
688
|
+
if (!childNode) continue;
|
|
689
|
+
children.push({
|
|
690
|
+
node: childNode,
|
|
691
|
+
edge,
|
|
692
|
+
handle: makeHandle(edge.to, scope)
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return children;
|
|
696
|
+
},
|
|
697
|
+
drivers(signal) {
|
|
698
|
+
return getJson(fetcher, `${base()}/${encodeURIComponent(id)}/sensitivity`, signal);
|
|
699
|
+
},
|
|
700
|
+
explain(opts, signal) {
|
|
701
|
+
if (scoped) throw new WorldModelScopeUnsupportedError("explain", scope);
|
|
702
|
+
return postJson(fetcher, `${base()}/explain`, {
|
|
703
|
+
target: id,
|
|
704
|
+
...opts
|
|
705
|
+
}, signal);
|
|
706
|
+
},
|
|
707
|
+
size(opts, signal) {
|
|
708
|
+
if (scoped) throw new WorldModelScopeUnsupportedError("size", scope);
|
|
709
|
+
return postJson(fetcher, `${base()}/opportunity`, {
|
|
710
|
+
target: id,
|
|
711
|
+
...opts
|
|
712
|
+
}, signal);
|
|
713
|
+
},
|
|
714
|
+
drill(next) {
|
|
715
|
+
return makeHandle(id, {
|
|
716
|
+
...scope,
|
|
717
|
+
...next
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
};
|
|
722
|
+
return {
|
|
723
|
+
projectId,
|
|
724
|
+
tree,
|
|
725
|
+
metric: (id) => makeHandle(id, {})
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The World Model node interface, scoped to the active `<OxyAppProvider>`
|
|
730
|
+
* project. Returns a stable {@link WorldModelApi} — grab a node with
|
|
731
|
+
* `world.metric(id)` and let it speak the verbs.
|
|
732
|
+
*
|
|
733
|
+
* @example
|
|
734
|
+
* ```tsx
|
|
735
|
+
* const world = useWorldModel();
|
|
736
|
+
* const revenue = world.metric("orders.net_revenue");
|
|
737
|
+
* const children = await revenue.expand(); // components + drivers
|
|
738
|
+
* const rca = await revenue.explain({
|
|
739
|
+
* time_dimension: "orders.order_date",
|
|
740
|
+
* current_period: ["2026-06-01", "2026-06-30"],
|
|
741
|
+
* previous_period: ["2026-05-01", "2026-05-31"],
|
|
742
|
+
* });
|
|
743
|
+
* ```
|
|
744
|
+
*
|
|
745
|
+
* @remarks
|
|
746
|
+
* This is the node-paradigm hook. For the raw semantic-layer entity/measure
|
|
747
|
+
* graph, use {@link useWorldModelGraph} instead.
|
|
748
|
+
*/
|
|
749
|
+
function useWorldModel() {
|
|
750
|
+
const { projectId, fetcher } = useOxyApp();
|
|
751
|
+
return React.useMemo(() => createWorldModel(projectId ?? null, fetcher), [projectId, fetcher]);
|
|
752
|
+
}
|
|
753
|
+
|
|
107
754
|
//#endregion
|
|
108
755
|
//#region src/metricTree.ts
|
|
109
756
|
/**
|
|
@@ -233,5 +880,5 @@ var MetricTreeClient = class {
|
|
|
233
880
|
};
|
|
234
881
|
|
|
235
882
|
//#endregion
|
|
236
|
-
export { AnomaliesClient, MetricTreeClient, OxyAnswer, OxyApiError, OxyAppProvider, OxyChat,
|
|
883
|
+
export { AnomaliesClient, MetricTreeClient, OxyAnswer, OxyApiError, OxyAppProvider, OxyChat, WorldModelScopeUnsupportedError, _resetCustomAppManifestCacheForTest, apiErrorFromResponse, base64ToBytes, bytesToBase64, createWorldModel, getCustomAppDebug, getOxyAppLogger, interpretCustomAppError, loadCustomAppManifest, readInjectedAppConfig, readJsonSseStream, setOxyAppLogger, useAgentRun, useDistribution, useExplain, useFunction, useMeasureBreakdown, useMetricTree, useOpportunity, useOxyApp, usePredict, useProcedureRun, useQuery, useResolvedManifest, useSemanticQuery, useSensitivity, useTimeDimensions, useTrackEvent, useWorldModel, useWorldModelGraph, useWorldModelInstances };
|
|
237
884
|
//# sourceMappingURL=index.mjs.map
|