@burdenoff/microfe-buildmyiq 2026.1029.3 → 2026.1029.5

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.
Files changed (57) hide show
  1. package/README.md +18 -0
  2. package/assets/mediapipe/NOTICE.md +17 -0
  3. package/assets/mediapipe/blaze_face_short_range.tflite +0 -0
  4. package/dist/buildmyiq/hooks/aiProctoringSignals.d.ts +84 -0
  5. package/dist/buildmyiq/hooks/aiProctoringSignals.d.ts.map +1 -0
  6. package/dist/buildmyiq/hooks/aiProctoringSignals.js +133 -0
  7. package/dist/buildmyiq/hooks/aiProctoringSignals.js.map +1 -0
  8. package/dist/buildmyiq/hooks/index.d.ts +1 -0
  9. package/dist/buildmyiq/hooks/index.d.ts.map +1 -1
  10. package/dist/buildmyiq/hooks/index.js +1 -0
  11. package/dist/buildmyiq/hooks/useBuiProctoring.d.ts +12 -1
  12. package/dist/buildmyiq/hooks/useBuiProctoring.d.ts.map +1 -1
  13. package/dist/buildmyiq/hooks/useBuiProctoring.js +81 -49
  14. package/dist/buildmyiq/hooks/useBuiProctoring.js.map +1 -1
  15. package/dist/buildmyiq/hooks/useBuildmyiqApi.d.ts +1 -0
  16. package/dist/buildmyiq/hooks/useBuildmyiqApi.d.ts.map +1 -1
  17. package/dist/buildmyiq/hooks/useBuildmyiqApi.js +1 -1
  18. package/dist/buildmyiq/hooks/useBuildmyiqApi.js.map +1 -1
  19. package/dist/buildmyiq/pages/assessments/AiMonitoringConsent.d.ts +10 -0
  20. package/dist/buildmyiq/pages/assessments/AiMonitoringConsent.d.ts.map +1 -0
  21. package/dist/buildmyiq/pages/assessments/AiMonitoringConsent.js +111 -0
  22. package/dist/buildmyiq/pages/assessments/AiMonitoringConsent.js.map +1 -0
  23. package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.d.ts.map +1 -1
  24. package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.js +236 -232
  25. package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.js.map +1 -1
  26. package/dist/buildmyiq/pages/assessments/AssessmentsPage.d.ts.map +1 -1
  27. package/dist/buildmyiq/pages/assessments/AssessmentsPage.js +27 -3
  28. package/dist/buildmyiq/pages/assessments/AssessmentsPage.js.map +1 -1
  29. package/dist/buildmyiq/pages/assessments/ProctoringGate.d.ts +1 -1
  30. package/dist/buildmyiq/pages/assessments/ProctoringGate.d.ts.map +1 -1
  31. package/dist/buildmyiq/pages/assessments/ProctoringGate.js +176 -130
  32. package/dist/buildmyiq/pages/assessments/ProctoringGate.js.map +1 -1
  33. package/dist/buildmyiq/pages/assessments/faceDetection.d.ts +17 -0
  34. package/dist/buildmyiq/pages/assessments/faceDetection.d.ts.map +1 -0
  35. package/dist/buildmyiq/pages/assessments/faceDetection.js +36 -0
  36. package/dist/buildmyiq/pages/assessments/faceDetection.js.map +1 -0
  37. package/dist/buildmyiq/pages/assessments/proctoringAssetPaths.d.ts +19 -0
  38. package/dist/buildmyiq/pages/assessments/proctoringAssetPaths.d.ts.map +1 -0
  39. package/dist/buildmyiq/pages/assessments/proctoringAssetPaths.js +6 -0
  40. package/dist/buildmyiq/pages/assessments/proctoringAssetPaths.js.map +1 -0
  41. package/dist/buildmyiq/pages/assessments/useAiProctoringMonitor.d.ts +25 -0
  42. package/dist/buildmyiq/pages/assessments/useAiProctoringMonitor.d.ts.map +1 -0
  43. package/dist/buildmyiq/pages/assessments/useAiProctoringMonitor.js +145 -0
  44. package/dist/buildmyiq/pages/assessments/useAiProctoringMonitor.js.map +1 -0
  45. package/dist/buildmyiq/pages/assessments/useProctoringSignalQueue.d.ts +8 -0
  46. package/dist/buildmyiq/pages/assessments/useProctoringSignalQueue.d.ts.map +1 -0
  47. package/dist/buildmyiq/pages/assessments/useProctoringSignalQueue.js +50 -0
  48. package/dist/buildmyiq/pages/assessments/useProctoringSignalQueue.js.map +1 -0
  49. package/dist/buildmyiq/pages/proctoring/ProctoringReviewPage.d.ts.map +1 -1
  50. package/dist/buildmyiq/pages/proctoring/ProctoringReviewPage.js +165 -67
  51. package/dist/buildmyiq/pages/proctoring/ProctoringReviewPage.js.map +1 -1
  52. package/dist/buildmyiq/types/index.d.ts +43 -0
  53. package/dist/buildmyiq/types/index.d.ts.map +1 -1
  54. package/dist/buildmyiq/types/index.js.map +1 -1
  55. package/package.json +8 -1
  56. package/vite/proctoring-assets.d.mts +20 -0
  57. package/vite/proctoring-assets.mjs +154 -0
package/README.md CHANGED
@@ -10,3 +10,21 @@ See `AGENTS.md` for development commands and architecture details.
10
10
  ## License
11
11
 
12
12
  Proprietary — Copyright Burdenoff Consultancy Services Pvt. Ltd.
13
+
14
+ ## AI proctoring assets (app shells)
15
+
16
+ AI-monitored exams run a face detector in the learner's browser. Its
17
+ WebAssembly runtime and model are served by the app shell from its own origin,
18
+ never from a public CDN. Every shell that mounts this microfrontend must add
19
+ the plugin below to its Vite config; it emits the files under
20
+ `/static/vendor/mediapipe/` at build time and serves them in `vite dev`:
21
+
22
+ ```ts
23
+ import { buildmyiqProctoringAssets } from '@burdenoff/microfe-buildmyiq/vite';
24
+
25
+ export default defineConfig({ plugins: [buildmyiqProctoringAssets() /* , … */] });
26
+ ```
27
+
28
+ `@mediapipe/tasks-vision` is pinned to 0.10.35 because 1.0.0 and later send
29
+ usage metrics to Google with no opt-out. `proctoringAssets.test.ts` fails if an
30
+ upgrade reintroduces a third-party endpoint.
@@ -0,0 +1,17 @@
1
+ # Third-party model: BlazeFace short-range
2
+
3
+ `blaze_face_short_range.tflite` is the MediaPipe BlazeFace short-range face
4
+ detector (float16, model version 1), published by Google under the Apache
5
+ License 2.0.
6
+
7
+ - Source: https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite
8
+ - Model card: https://storage.googleapis.com/mediapipe-assets/MediaPipe%20BlazeFace%20Model%20Card%20(Short%20Range).pdf
9
+ - Size: 229,746 bytes
10
+ - SHA-256: `b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f`
11
+ - MD5 (matches the bucket's `x-goog-hash`): `a3dd6ec31725290770b97cec0cbf94c9`
12
+
13
+ It is vendored here so that BuildMyIQ app shells can serve it from their own
14
+ origin (BOFF-10118) through the `buildmyiqProctoringAssets()` Vite plugin in
15
+ `vite/proctoring-assets.mjs`. To replace it, download the new model, update the
16
+ URL path in both `vite/proctoring-assets.mjs` and
17
+ `src/buildmyiq/pages/assessments/proctoringAssetPaths.ts`, and update this file.
@@ -0,0 +1,84 @@
1
+ import { BuiProctoringSignalInput, BuiProctoringSignalType } from '../types';
2
+ /** Lockdown monitor signal names (see `useLockdownMonitor`) mapped to backend types. */
3
+ export declare const LOCKDOWN_SIGNAL_TYPE: {
4
+ readonly 'tab-hidden': "TAB_HIDDEN";
5
+ readonly 'window-blur': "WINDOW_BLUR";
6
+ readonly 'fullscreen-exit': "FULLSCREEN_EXIT";
7
+ readonly clipboard: "CLIPBOARD";
8
+ };
9
+ /** The conditions the AI monitor tracks as episodes. */
10
+ export type AiCondition = 'FACE_ABSENT' | 'MULTIPLE_FACES' | 'GAZE_AWAY' | 'VOICE_DETECTED';
11
+ /** How long a condition must last before it counts as an episode. */
12
+ export declare const EPISODE_MIN_MS: Record<AiCondition, number>;
13
+ /** A condition must stay clear this long before its episode closes (absorbs flicker). */
14
+ export declare const EPISODE_RELEASE_MS = 1500;
15
+ /** |yaw ratio| above this means the head is turned well away from the screen. */
16
+ export declare const GAZE_AWAY_YAW_RATIO = 0.5;
17
+ /** Speech must be this many dB above the calibrated noise floor... */
18
+ export declare const SPEECH_MARGIN_DB = 12;
19
+ /** ...and never quieter than this absolute level. */
20
+ export declare const SPEECH_MIN_LEVEL_DB = -65;
21
+ export interface EpisodeSample {
22
+ confidence?: number | null;
23
+ faceCount?: number | null;
24
+ levelDb?: number | null;
25
+ }
26
+ /**
27
+ * Turns per-sample condition flags into closed episodes. Call `update` on
28
+ * every sample; it returns any episodes that closed on that sample. Call
29
+ * `flush` when monitoring stops to close whatever is still open.
30
+ */
31
+ export declare class EpisodeTracker {
32
+ private open;
33
+ update(condition: AiCondition, active: boolean, now: number, sample?: EpisodeSample): BuiProctoringSignalInput[];
34
+ /** True while the condition has an episode in progress that already meets its minimum. */
35
+ isOngoing(condition: AiCondition, now: number): boolean;
36
+ /** Close every open episode (monitoring stopped). */
37
+ flush(): BuiProctoringSignalInput[];
38
+ }
39
+ export interface Keypoint {
40
+ x: number;
41
+ y: number;
42
+ }
43
+ /**
44
+ * Head-turn estimate from BlazeFace keypoints (0 right eye, 1 left eye,
45
+ * 2 nose tip): how far the nose sits from the midpoint of the eyes, in units
46
+ * of the distance between the eyes. About 0 when facing the screen; beyond
47
+ * ±0.5 the head is turned roughly 40° or more. Null when the keypoints are
48
+ * missing or degenerate.
49
+ */
50
+ export declare function estimateYawRatio(keypoints: readonly Keypoint[] | undefined): number | null;
51
+ export declare function isLookingAway(yawRatio: number | null): boolean;
52
+ /**
53
+ * Average level (dBFS) of the speech band (300–3400 Hz) from an
54
+ * AnalyserNode's `getFloatFrequencyData` output. Averages in the power
55
+ * domain, so a loud voice is not drowned out by quiet bins.
56
+ */
57
+ export declare function speechBandLevelDb(frequencyDb: ArrayLike<number>, sampleRate: number, fftSize: number, lowHz?: number, highHz?: number): number;
58
+ /**
59
+ * Tracks the room's noise floor: the 20th-percentile of recent levels. Needs
60
+ * a few seconds of samples before it will call anything speech.
61
+ */
62
+ export declare class NoiseFloor {
63
+ private readonly windowSize;
64
+ private readonly minSamples;
65
+ private samples;
66
+ constructor(windowSize?: number, minSamples?: number);
67
+ add(levelDb: number): void;
68
+ /** Current floor, or null while still calibrating. */
69
+ value(): number | null;
70
+ isSpeech(levelDb: number): boolean;
71
+ }
72
+ /** Human summary of a signal type for reviewers and the learner banner. */
73
+ export declare const SIGNAL_LABELS: Record<BuiProctoringSignalType, string>;
74
+ /** AI-derived signal types (shown with an "AI" marker to reviewers). */
75
+ export declare const AI_SIGNAL_TYPES: readonly BuiProctoringSignalType[];
76
+ /** Published retention period for proctoring signals (matches the backend). */
77
+ export declare const PROCTORING_SIGNAL_RETENTION_DAYS = 60;
78
+ /** The backend accepts at most this many signals per call. */
79
+ export declare const MAX_SIGNALS_PER_BATCH = 50;
80
+ /** Split a queue into backend-sized batches. */
81
+ export declare function toBatches<T>(items: readonly T[], size?: number): T[][];
82
+ /** "1 min 05 s" style duration for reviewer timelines. */
83
+ export declare function formatEpisodeDuration(ms: number): string;
84
+ //# sourceMappingURL=aiProctoringSignals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aiProctoringSignals.d.ts","sourceRoot":"","sources":["../../../src/buildmyiq/hooks/aiProctoringSignals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAElF,wFAAwF;AACxF,eAAO,MAAM,oBAAoB;;;;;CAK2B,CAAC;AAE7D,wDAAwD;AACxD,MAAM,MAAM,WAAW,GAAG,aAAa,GAAG,gBAAgB,GAAG,WAAW,GAAG,gBAAgB,CAAC;AAE5F,qEAAqE;AACrE,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAKtD,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,OAAQ,CAAC;AAExC,iFAAiF;AACjF,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,sEAAsE;AACtE,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,qDAAqD;AACrD,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAeD;;;;GAIG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,IAAI,CAAuC;IAEnD,MAAM,CACJ,SAAS,EAAE,WAAW,EACtB,MAAM,EAAE,OAAO,EACf,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,aAAkB,GACzB,wBAAwB,EAAE;IAyB7B,0FAA0F;IAC1F,SAAS,CAAC,SAAS,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAKvD,qDAAqD;IACrD,KAAK,IAAI,wBAAwB,EAAE;CASpC;AAuBD,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,SAAS,QAAQ,EAAE,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAO1F;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAE9D;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,SAAS,CAAC,MAAM,CAAC,EAC9B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,KAAK,SAAM,EACX,MAAM,SAAO,GACZ,MAAM,CAcR;AAED;;;GAGG;AACH,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAJ7B,OAAO,CAAC,OAAO,CAAgB;gBAGZ,UAAU,SAAK,EACf,UAAU,SAAI;IAGjC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAM1B,sDAAsD;IACtD,KAAK,IAAI,MAAM,GAAG,IAAI;IAMtB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;CAKnC;AAED,2EAA2E;AAC3E,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,uBAAuB,EAAE,MAAM,CAWjE,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,eAAe,EAAE,SAAS,uBAAuB,EAO7D,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAEnD,8DAA8D;AAC9D,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,gDAAgD;AAChD,wBAAgB,SAAS,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,EAAE,IAAI,SAAwB,GAAG,CAAC,EAAE,EAAE,CAIrF;AAED,0DAA0D;AAC1D,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAOxD"}
@@ -0,0 +1,133 @@
1
+ //#region src/buildmyiq/hooks/aiProctoringSignals.ts
2
+ var e = {
3
+ "tab-hidden": "TAB_HIDDEN",
4
+ "window-blur": "WINDOW_BLUR",
5
+ "fullscreen-exit": "FULLSCREEN_EXIT",
6
+ clipboard: "CLIPBOARD"
7
+ }, t = {
8
+ FACE_ABSENT: 3e3,
9
+ MULTIPLE_FACES: 2e3,
10
+ GAZE_AWAY: 4e3,
11
+ VOICE_DETECTED: 3e3
12
+ }, n = 1500, r = .5;
13
+ function i(e, t) {
14
+ return t == null || !Number.isFinite(t) ? e : e == null ? t : Math.max(e, t);
15
+ }
16
+ var a = class {
17
+ open = /* @__PURE__ */ new Map();
18
+ update(e, t, n, r = {}) {
19
+ let a = this.open.get(e);
20
+ if (t) {
21
+ let t = a ?? {
22
+ startedAt: n,
23
+ lastActiveAt: n,
24
+ confidence: null,
25
+ faceCount: null,
26
+ peakLevelDb: null
27
+ };
28
+ return t.lastActiveAt = n, t.confidence = i(t.confidence, r.confidence), t.faceCount = i(t.faceCount, r.faceCount), t.peakLevelDb = i(t.peakLevelDb, r.levelDb), this.open.set(e, t), [];
29
+ }
30
+ if (a && n - a.lastActiveAt >= 1500) {
31
+ this.open.delete(e);
32
+ let t = o(e, a);
33
+ return t ? [t] : [];
34
+ }
35
+ return [];
36
+ }
37
+ isOngoing(e, n) {
38
+ let r = this.open.get(e);
39
+ return r != null && n - r.startedAt >= t[e];
40
+ }
41
+ flush() {
42
+ let e = [];
43
+ for (let [t, n] of this.open) {
44
+ let r = o(t, n);
45
+ r && e.push(r);
46
+ }
47
+ return this.open.clear(), e;
48
+ }
49
+ };
50
+ function o(e, n) {
51
+ let r = Math.round(n.lastActiveAt - n.startedAt);
52
+ if (r < t[e]) return null;
53
+ let i = {
54
+ type: e,
55
+ startedAt: new Date(n.startedAt).toISOString(),
56
+ durationMs: r
57
+ };
58
+ return n.confidence != null && (i.confidence = Math.min(1, Math.max(0, s(n.confidence, 3)))), n.faceCount != null && (i.faceCount = Math.min(20, Math.round(n.faceCount))), n.peakLevelDb != null && (i.peakLevelDb = Math.min(0, Math.max(-160, s(n.peakLevelDb, 1)))), i;
59
+ }
60
+ function s(e, t) {
61
+ let n = 10 ** t;
62
+ return Math.round(e * n) / n;
63
+ }
64
+ function c(e) {
65
+ if (!e || e.length < 3) return null;
66
+ let [t, n, r] = e, i = Math.abs(n.x - t.x);
67
+ if (!Number.isFinite(i) || i < .001) return null;
68
+ let a = (n.x + t.x) / 2;
69
+ return (r.x - a) / i;
70
+ }
71
+ function l(e) {
72
+ return e != null && Math.abs(e) > .5;
73
+ }
74
+ function u(e, t, n, r = 300, i = 3400) {
75
+ let a = t / n, o = Math.max(0, Math.floor(r / a)), s = Math.min(e.length - 1, Math.ceil(i / a)), c = 0, l = 0;
76
+ for (let t = o; t <= s; t++) {
77
+ let n = e[t];
78
+ n == null || !Number.isFinite(n) || (c += 10 ** (n / 10), l += 1);
79
+ }
80
+ return l === 0 || c <= 0 ? -160 : 10 * Math.log10(c / l);
81
+ }
82
+ var d = class {
83
+ windowSize;
84
+ minSamples;
85
+ samples = [];
86
+ constructor(e = 60, t = 5) {
87
+ this.windowSize = e, this.minSamples = t;
88
+ }
89
+ add(e) {
90
+ Number.isFinite(e) && (this.samples.push(e), this.samples.length > this.windowSize && this.samples.shift());
91
+ }
92
+ value() {
93
+ if (this.samples.length < this.minSamples) return null;
94
+ let e = [...this.samples].sort((e, t) => e - t);
95
+ return e[Math.floor(e.length * .2)] ?? null;
96
+ }
97
+ isSpeech(e) {
98
+ let t = this.value();
99
+ return t == null ? !1 : e >= -65 && e - t >= 12;
100
+ }
101
+ }, f = {
102
+ TAB_HIDDEN: "Switched tab or app",
103
+ WINDOW_BLUR: "Exam window lost focus",
104
+ FULLSCREEN_EXIT: "Left fullscreen",
105
+ CLIPBOARD: "Copy, cut or paste",
106
+ FACE_ABSENT: "No face in camera view",
107
+ MULTIPLE_FACES: "More than one face",
108
+ GAZE_AWAY: "Looking away from the screen",
109
+ VOICE_DETECTED: "Sustained speech or voices",
110
+ CAMERA_UNAVAILABLE: "Camera stopped",
111
+ MICROPHONE_UNAVAILABLE: "Microphone stopped"
112
+ }, p = [
113
+ "FACE_ABSENT",
114
+ "MULTIPLE_FACES",
115
+ "GAZE_AWAY",
116
+ "VOICE_DETECTED",
117
+ "CAMERA_UNAVAILABLE",
118
+ "MICROPHONE_UNAVAILABLE"
119
+ ];
120
+ function m(e, t = 50) {
121
+ let n = [];
122
+ for (let r = 0; r < e.length; r += t) n.push(e.slice(r, r + t));
123
+ return n;
124
+ }
125
+ function h(e) {
126
+ if (e <= 0) return "";
127
+ let t = Math.round(e / 1e3), n = Math.floor(t / 60), r = t % 60;
128
+ return n === 0 ? `${r} s` : `${n} min ${String(r).padStart(2, "0")} s`;
129
+ }
130
+ //#endregion
131
+ export { p as AI_SIGNAL_TYPES, t as EPISODE_MIN_MS, n as EPISODE_RELEASE_MS, a as EpisodeTracker, r as GAZE_AWAY_YAW_RATIO, e as LOCKDOWN_SIGNAL_TYPE, d as NoiseFloor, f as SIGNAL_LABELS, c as estimateYawRatio, h as formatEpisodeDuration, l as isLookingAway, u as speechBandLevelDb, m as toBatches };
132
+
133
+ //# sourceMappingURL=aiProctoringSignals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aiProctoringSignals.js","names":[],"sources":["../../../src/buildmyiq/hooks/aiProctoringSignals.ts"],"sourcesContent":["/**\n * Pure rules for in-browser AI proctoring signals (BOFF-9690).\n * @module buildmyiq/hooks/aiProctoringSignals\n *\n * The AI monitor samples the learner's camera and microphone on their own\n * device and turns sustained conditions into signal episodes:\n *\n * - FACE_ABSENT no face in frame for at least 3 s\n * - MULTIPLE_FACES more than one face for at least 2 s\n * - GAZE_AWAY head turned well away from the screen for at least 4 s\n * - VOICE_DETECTED speech-band audio well above the room's noise floor for at least 3 s\n *\n * Nothing here touches images or audio buffers beyond a single number per\n * sample (face count, a head-turn ratio, a level in dBFS); the episodes are\n * the only thing sent to the backend. Everything is pure so it can be unit\n * tested without a camera.\n */\n\nimport type { BuiProctoringSignalInput, BuiProctoringSignalType } from '../types';\n\n/** Lockdown monitor signal names (see `useLockdownMonitor`) mapped to backend types. */\nexport const LOCKDOWN_SIGNAL_TYPE = {\n 'tab-hidden': 'TAB_HIDDEN',\n 'window-blur': 'WINDOW_BLUR',\n 'fullscreen-exit': 'FULLSCREEN_EXIT',\n clipboard: 'CLIPBOARD',\n} as const satisfies Record<string, BuiProctoringSignalType>;\n\n/** The conditions the AI monitor tracks as episodes. */\nexport type AiCondition = 'FACE_ABSENT' | 'MULTIPLE_FACES' | 'GAZE_AWAY' | 'VOICE_DETECTED';\n\n/** How long a condition must last before it counts as an episode. */\nexport const EPISODE_MIN_MS: Record<AiCondition, number> = {\n FACE_ABSENT: 3_000,\n MULTIPLE_FACES: 2_000,\n GAZE_AWAY: 4_000,\n VOICE_DETECTED: 3_000,\n};\n\n/** A condition must stay clear this long before its episode closes (absorbs flicker). */\nexport const EPISODE_RELEASE_MS = 1_500;\n\n/** |yaw ratio| above this means the head is turned well away from the screen. */\nexport const GAZE_AWAY_YAW_RATIO = 0.5;\n\n/** Speech must be this many dB above the calibrated noise floor... */\nexport const SPEECH_MARGIN_DB = 12;\n/** ...and never quieter than this absolute level. */\nexport const SPEECH_MIN_LEVEL_DB = -65;\n\nexport interface EpisodeSample {\n confidence?: number | null;\n faceCount?: number | null;\n levelDb?: number | null;\n}\n\ninterface OpenEpisode {\n startedAt: number;\n lastActiveAt: number;\n confidence: number | null;\n faceCount: number | null;\n peakLevelDb: number | null;\n}\n\nfunction maxOrNull(a: number | null, b: number | null | undefined): number | null {\n if (b == null || !Number.isFinite(b)) return a;\n return a == null ? b : Math.max(a, b);\n}\n\n/**\n * Turns per-sample condition flags into closed episodes. Call `update` on\n * every sample; it returns any episodes that closed on that sample. Call\n * `flush` when monitoring stops to close whatever is still open.\n */\nexport class EpisodeTracker {\n private open = new Map<AiCondition, OpenEpisode>();\n\n update(\n condition: AiCondition,\n active: boolean,\n now: number,\n sample: EpisodeSample = {}\n ): BuiProctoringSignalInput[] {\n const current = this.open.get(condition);\n if (active) {\n const episode = current ?? {\n startedAt: now,\n lastActiveAt: now,\n confidence: null,\n faceCount: null,\n peakLevelDb: null,\n };\n episode.lastActiveAt = now;\n episode.confidence = maxOrNull(episode.confidence, sample.confidence);\n episode.faceCount = maxOrNull(episode.faceCount, sample.faceCount);\n episode.peakLevelDb = maxOrNull(episode.peakLevelDb, sample.levelDb);\n this.open.set(condition, episode);\n return [];\n }\n if (current && now - current.lastActiveAt >= EPISODE_RELEASE_MS) {\n this.open.delete(condition);\n const closed = toSignal(condition, current);\n return closed ? [closed] : [];\n }\n return [];\n }\n\n /** True while the condition has an episode in progress that already meets its minimum. */\n isOngoing(condition: AiCondition, now: number): boolean {\n const episode = this.open.get(condition);\n return episode != null && now - episode.startedAt >= EPISODE_MIN_MS[condition];\n }\n\n /** Close every open episode (monitoring stopped). */\n flush(): BuiProctoringSignalInput[] {\n const out: BuiProctoringSignalInput[] = [];\n for (const [condition, episode] of this.open) {\n const closed = toSignal(condition, episode);\n if (closed) out.push(closed);\n }\n this.open.clear();\n return out;\n }\n}\n\nfunction toSignal(condition: AiCondition, episode: OpenEpisode): BuiProctoringSignalInput | null {\n const durationMs = Math.round(episode.lastActiveAt - episode.startedAt);\n if (durationMs < EPISODE_MIN_MS[condition]) return null;\n const signal: BuiProctoringSignalInput = {\n type: condition,\n startedAt: new Date(episode.startedAt).toISOString(),\n durationMs,\n };\n if (episode.confidence != null)\n signal.confidence = Math.min(1, Math.max(0, round(episode.confidence, 3)));\n if (episode.faceCount != null) signal.faceCount = Math.min(20, Math.round(episode.faceCount));\n if (episode.peakLevelDb != null)\n signal.peakLevelDb = Math.min(0, Math.max(-160, round(episode.peakLevelDb, 1)));\n return signal;\n}\n\nfunction round(value: number, digits: number): number {\n const f = 10 ** digits;\n return Math.round(value * f) / f;\n}\n\nexport interface Keypoint {\n x: number;\n y: number;\n}\n\n/**\n * Head-turn estimate from BlazeFace keypoints (0 right eye, 1 left eye,\n * 2 nose tip): how far the nose sits from the midpoint of the eyes, in units\n * of the distance between the eyes. About 0 when facing the screen; beyond\n * ±0.5 the head is turned roughly 40° or more. Null when the keypoints are\n * missing or degenerate.\n */\nexport function estimateYawRatio(keypoints: readonly Keypoint[] | undefined): number | null {\n if (!keypoints || keypoints.length < 3) return null;\n const [rightEye, leftEye, nose] = keypoints as [Keypoint, Keypoint, Keypoint];\n const eyeDistance = Math.abs(leftEye.x - rightEye.x);\n if (!Number.isFinite(eyeDistance) || eyeDistance < 1e-3) return null;\n const mid = (leftEye.x + rightEye.x) / 2;\n return (nose.x - mid) / eyeDistance;\n}\n\nexport function isLookingAway(yawRatio: number | null): boolean {\n return yawRatio != null && Math.abs(yawRatio) > GAZE_AWAY_YAW_RATIO;\n}\n\n/**\n * Average level (dBFS) of the speech band (300–3400 Hz) from an\n * AnalyserNode's `getFloatFrequencyData` output. Averages in the power\n * domain, so a loud voice is not drowned out by quiet bins.\n */\nexport function speechBandLevelDb(\n frequencyDb: ArrayLike<number>,\n sampleRate: number,\n fftSize: number,\n lowHz = 300,\n highHz = 3400\n): number {\n const binHz = sampleRate / fftSize;\n const from = Math.max(0, Math.floor(lowHz / binHz));\n const to = Math.min(frequencyDb.length - 1, Math.ceil(highHz / binHz));\n let power = 0;\n let bins = 0;\n for (let i = from; i <= to; i++) {\n const db = frequencyDb[i];\n if (db == null || !Number.isFinite(db)) continue;\n power += 10 ** (db / 10);\n bins += 1;\n }\n if (bins === 0 || power <= 0) return -160;\n return 10 * Math.log10(power / bins);\n}\n\n/**\n * Tracks the room's noise floor: the 20th-percentile of recent levels. Needs\n * a few seconds of samples before it will call anything speech.\n */\nexport class NoiseFloor {\n private samples: number[] = [];\n\n constructor(\n private readonly windowSize = 60,\n private readonly minSamples = 5\n ) {}\n\n add(levelDb: number): void {\n if (!Number.isFinite(levelDb)) return;\n this.samples.push(levelDb);\n if (this.samples.length > this.windowSize) this.samples.shift();\n }\n\n /** Current floor, or null while still calibrating. */\n value(): number | null {\n if (this.samples.length < this.minSamples) return null;\n const sorted = [...this.samples].sort((a, b) => a - b);\n return sorted[Math.floor(sorted.length * 0.2)] ?? null;\n }\n\n isSpeech(levelDb: number): boolean {\n const floor = this.value();\n if (floor == null) return false;\n return levelDb >= SPEECH_MIN_LEVEL_DB && levelDb - floor >= SPEECH_MARGIN_DB;\n }\n}\n\n/** Human summary of a signal type for reviewers and the learner banner. */\nexport const SIGNAL_LABELS: Record<BuiProctoringSignalType, string> = {\n TAB_HIDDEN: 'Switched tab or app',\n WINDOW_BLUR: 'Exam window lost focus',\n FULLSCREEN_EXIT: 'Left fullscreen',\n CLIPBOARD: 'Copy, cut or paste',\n FACE_ABSENT: 'No face in camera view',\n MULTIPLE_FACES: 'More than one face',\n GAZE_AWAY: 'Looking away from the screen',\n VOICE_DETECTED: 'Sustained speech or voices',\n CAMERA_UNAVAILABLE: 'Camera stopped',\n MICROPHONE_UNAVAILABLE: 'Microphone stopped',\n};\n\n/** AI-derived signal types (shown with an \"AI\" marker to reviewers). */\nexport const AI_SIGNAL_TYPES: readonly BuiProctoringSignalType[] = [\n 'FACE_ABSENT',\n 'MULTIPLE_FACES',\n 'GAZE_AWAY',\n 'VOICE_DETECTED',\n 'CAMERA_UNAVAILABLE',\n 'MICROPHONE_UNAVAILABLE',\n];\n\n/** Published retention period for proctoring signals (matches the backend). */\nexport const PROCTORING_SIGNAL_RETENTION_DAYS = 60;\n\n/** The backend accepts at most this many signals per call. */\nexport const MAX_SIGNALS_PER_BATCH = 50;\n\n/** Split a queue into backend-sized batches. */\nexport function toBatches<T>(items: readonly T[], size = MAX_SIGNALS_PER_BATCH): T[][] {\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/** \"1 min 05 s\" style duration for reviewer timelines. */\nexport function formatEpisodeDuration(ms: number): string {\n if (ms <= 0) return '';\n const totalSeconds = Math.round(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n if (minutes === 0) return `${seconds} s`;\n return `${minutes} min ${String(seconds).padStart(2, '0')} s`;\n}\n"],"mappings":"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"}
@@ -10,6 +10,7 @@ export * from './useBuiTags';
10
10
  export * from './useBuiPermissions';
11
11
  export * from './useBuiProctoring';
12
12
  export * from './proctoringState';
13
+ export * from './aiProctoringSignals';
13
14
  export * from './useBuiCredentials';
14
15
  export * from './useBuiAccessibility';
15
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/buildmyiq/hooks/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/buildmyiq/hooks/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC"}
@@ -6,6 +6,7 @@ import "./useBuiTags.js";
6
6
  import "./useBuiPermissions.js";
7
7
  import "./useBuiProctoring.js";
8
8
  import "./proctoringState.js";
9
+ import "./aiProctoringSignals.js";
9
10
  import "./useBuiCredentials.js";
10
11
  import "./accessibilityDraft.js";
11
12
  import "./useBuiAccessibility.js";
@@ -1,4 +1,4 @@
1
- import { BuiConnection, BuiProctoringIdentityFile, BuiProctoringSession, BuiProctoringStatus, BuiReviewProctoringSessionInput, BuiSubmitProctoringIdentityInput } from '../types';
1
+ import { BuiConnection, BuiProctoringIdentityFile, BuiProctoringSession, BuiProctoringSignal, BuiProctoringSignalInput, BuiProctoringStatus, BuiReviewProctoringSessionInput, BuiSubmitProctoringIdentityInput } from '../types';
2
2
  /**
3
3
  * The calling learner's most recent proctoring session for an assessment
4
4
  * (null when they have never started one). Polls while a reviewer decision
@@ -12,6 +12,17 @@ export declare const useBuiSubmitProctoringIdentity: () => import('@tanstack/rea
12
12
  }, unknown>;
13
13
  /** Records one lockdown violation (tab switch, window blur, fullscreen exit, copy/paste). */
14
14
  export declare const useBuiReportProctoringViolation: () => import('@tanstack/react-query').UseMutationResult<BuiProctoringSession, Error, string, unknown>;
15
+ /**
16
+ * BOFF-9690: records a batch (max 50) of signal episodes on the learner's own
17
+ * approved session — lockdown events and, when the assessment opted in and
18
+ * the learner consented, in-browser AI signals.
19
+ */
20
+ export declare const useBuiRecordProctoringSignals: () => import('@tanstack/react-query').UseMutationResult<BuiProctoringSession, Error, {
21
+ id: string;
22
+ signals: BuiProctoringSignalInput[];
23
+ }, unknown>;
24
+ /** BOFF-9690: reviewer timeline of one session's signals, oldest first, 100 per page. */
25
+ export declare const useBuiProctoringSessionSignals: (sessionId: string | undefined, enabled?: boolean) => import('@tanstack/react-query').UseInfiniteQueryResult<import('@tanstack/query-core').InfiniteData<BuiConnection<BuiProctoringSignal>, unknown>, Error>;
15
26
  /** Reviewer queue: proctoring sessions in the workspace, optionally filtered by status. */
16
27
  export declare const useBuiProctoringSessions: (args: {
17
28
  status?: BuiProctoringStatus;
@@ -1 +1 @@
1
- {"version":3,"file":"useBuiProctoring.d.ts","sourceRoot":"","sources":["../../../src/buildmyiq/hooks/useBuiProctoring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,OAAO,KAAK,EACV,aAAa,EACb,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,+BAA+B,EAC/B,gCAAgC,EACjC,MAAM,UAAU,CAAC;AA6BlB;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,MAAM,GAAG,SAAS,EAAE,iBAAc,gGAwBzF,CAAC;AAEF,eAAO,MAAM,4BAA4B,uGAkBxC,CAAC;AAEF,eAAO,MAAM,8BAA8B;QAIR,MAAM;WAAS,gCAAgC;WAejF,CAAC;AAEF,6FAA6F;AAC7F,eAAO,MAAM,+BAA+B,uGAkB3C,CAAC;AAEF,2FAA2F;AAC3F,eAAO,MAAM,wBAAwB,GACnC,MAAM;IAAE,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC7E,iBAAc,wGAuBf,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GAAI,WAAW,MAAM,GAAG,SAAS,EAAE,iBAAc,qGAuBzF,CAAC;AAEF,eAAO,MAAM,6BAA6B;QAIP,MAAM;WAAS,+BAA+B;WAYhF,CAAC"}
1
+ {"version":3,"file":"useBuiProctoring.d.ts","sourceRoot":"","sources":["../../../src/buildmyiq/hooks/useBuiProctoring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,OAAO,KAAK,EACV,aAAa,EACb,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,wBAAwB,EACxB,mBAAmB,EACnB,+BAA+B,EAC/B,gCAAgC,EACjC,MAAM,UAAU,CAAC;AAkClB;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,MAAM,GAAG,SAAS,EAAE,iBAAc,gGAwBzF,CAAC;AAEF,eAAO,MAAM,4BAA4B,uGAkBxC,CAAC;AAEF,eAAO,MAAM,8BAA8B;QAIR,MAAM;WAAS,gCAAgC;WAejF,CAAC;AAEF,6FAA6F;AAC7F,eAAO,MAAM,+BAA+B,uGAkB3C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B;QAIP,MAAM;aAAW,wBAAwB,EAAE;WAc7E,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,8BAA8B,GAAI,WAAW,MAAM,GAAG,SAAS,EAAE,iBAAc,4JA2B3F,CAAC;AAEF,2FAA2F;AAC3F,eAAO,MAAM,wBAAwB,GACnC,MAAM;IAAE,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC7E,iBAAc,wGAuBf,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GAAI,WAAW,MAAM,GAAG,SAAS,EAAE,iBAAc,qGAuBzF,CAAC;AAEF,eAAO,MAAM,6BAA6B;QAIP,MAAM;WAAS,+BAA+B;WAYhF,CAAC"}
@@ -1,9 +1,9 @@
1
1
  import { useBuildmyiq as e } from "../context/BuildmyiqContext.js";
2
2
  import "../context/index.js";
3
3
  import { wspaceFetch as t } from "./useBuiFiles.js";
4
- import { useMutation as n, useQuery as r, useQueryClient as i } from "@tanstack/react-query";
4
+ import { useInfiniteQuery as n, useMutation as r, useQuery as i, useQueryClient as a } from "@tanstack/react-query";
5
5
  //#region src/buildmyiq/hooks/useBuiProctoring.ts
6
- var a = "\n id workspaceId assessmentId learnerId status lockdownAcknowledgedAt violationCount\n reviewedById reviewedAt reviewNotes expiresAt createdAt updatedAt\n identityFile { id }\n", o = {
6
+ var o = "\n id workspaceId assessmentId learnerId status lockdownAcknowledgedAt violationCount\n aiMonitoringConsentAt signalCount reviewedById reviewedAt reviewNotes expiresAt createdAt updatedAt\n identityFile { id }\n", s = {
7
7
  mine: (e) => [
8
8
  "buildmyiq",
9
9
  "proctoring",
@@ -20,96 +20,128 @@ var a = "\n id workspaceId assessmentId learnerId status lockdownAcknowledgedAt
20
20
  "proctoring",
21
21
  "identity-file",
22
22
  e
23
+ ],
24
+ signals: (e) => [
25
+ "buildmyiq",
26
+ "proctoring",
27
+ "signals",
28
+ e
23
29
  ]
24
- }, s = 4 * 6e4, c = 15e3, l = (n, i = !0) => {
25
- let { apiGatewayUrl: s } = e();
26
- return r({
27
- queryKey: n ? o.mine(n) : [
30
+ }, c = 4 * 6e4, l = "\n id sessionId type startedAt durationMs confidence faceCount peakLevelDb createdAt\n", u = 15e3, d = (n, r = !0) => {
31
+ let { apiGatewayUrl: a } = e();
32
+ return i({
33
+ queryKey: n ? s.mine(n) : [
28
34
  "buildmyiq",
29
35
  "proctoring",
30
36
  "mine",
31
37
  "nil"
32
38
  ],
33
- enabled: !!n && i,
39
+ enabled: !!n && r,
34
40
  queryFn: async () => (await t(`query BuiMyProctoringSession($assessmentId: ID!) {
35
- buiMyProctoringSession(assessmentId: $assessmentId) { ${a} }
36
- }`, { assessmentId: n }, s)).buiMyProctoringSession,
41
+ buiMyProctoringSession(assessmentId: $assessmentId) { ${o} }
42
+ }`, { assessmentId: n }, a)).buiMyProctoringSession,
37
43
  refetchInterval: (e) => {
38
44
  let t = e.state.data?.status;
39
- return t === "UNDER_REVIEW" || t === "IDENTITY_SUBMITTED" ? c : !1;
45
+ return t === "UNDER_REVIEW" || t === "IDENTITY_SUBMITTED" ? u : !1;
40
46
  }
41
47
  });
42
- }, u = () => {
43
- let { apiGatewayUrl: r } = e(), s = i();
44
- return n({
48
+ }, f = () => {
49
+ let { apiGatewayUrl: n } = e(), i = a();
50
+ return r({
45
51
  mutationFn: async (e) => (await t(`mutation BuiStartProctoringSession($assessmentId: ID!) {
46
- buiStartProctoringSession(assessmentId: $assessmentId) { ${a} }
47
- }`, { assessmentId: e }, r)).buiStartProctoringSession,
52
+ buiStartProctoringSession(assessmentId: $assessmentId) { ${o} }
53
+ }`, { assessmentId: e }, n)).buiStartProctoringSession,
48
54
  onSuccess: (e) => {
49
- s.setQueryData(o.mine(e.assessmentId), e);
55
+ i.setQueryData(s.mine(e.assessmentId), e);
50
56
  }
51
57
  });
52
- }, d = () => {
53
- let { apiGatewayUrl: r } = e(), s = i();
54
- return n({
58
+ }, p = () => {
59
+ let { apiGatewayUrl: n } = e(), i = a();
60
+ return r({
55
61
  mutationFn: async (e) => (await t(`mutation BuiSubmitProctoringIdentity($id: ID!, $input: BuiSubmitProctoringIdentityInput!) {
56
- buiSubmitProctoringIdentity(id: $id, input: $input) { ${a} }
57
- }`, e, r)).buiSubmitProctoringIdentity,
62
+ buiSubmitProctoringIdentity(id: $id, input: $input) { ${o} }
63
+ }`, e, n)).buiSubmitProctoringIdentity,
58
64
  onSuccess: (e) => {
59
- s.setQueryData(o.mine(e.assessmentId), e), s.invalidateQueries({ queryKey: o.queue });
65
+ i.setQueryData(s.mine(e.assessmentId), e), i.invalidateQueries({ queryKey: s.queue });
60
66
  }
61
67
  });
62
- }, f = () => {
63
- let { apiGatewayUrl: r } = e(), s = i();
64
- return n({
65
- mutationFn: async (e) => (await t(`mutation BuiReportProctoringViolation($id: ID!) {
66
- buiReportProctoringViolation(id: $id) { ${a} }
67
- }`, { id: e }, r)).buiReportProctoringViolation,
68
+ }, m = () => {
69
+ let { apiGatewayUrl: n } = e(), i = a();
70
+ return r({
71
+ mutationFn: async (e) => (await t(`mutation BuiRecordProctoringSignals($id: ID!, $signals: [BuiProctoringSignalInput!]!) {
72
+ buiRecordProctoringSignals(id: $id, signals: $signals) { ${o} }
73
+ }`, e, n)).buiRecordProctoringSignals,
68
74
  onSuccess: (e) => {
69
- s.setQueryData(o.mine(e.assessmentId), e);
75
+ i.setQueryData(s.mine(e.assessmentId), e);
70
76
  }
71
77
  });
72
- }, p = (n, i = !0) => {
73
- let { apiGatewayUrl: s } = e(), { status: c, assessmentId: l, limit: u = 50 } = n;
74
- return r({
78
+ }, h = (r, i = !0) => {
79
+ let { apiGatewayUrl: a } = e();
80
+ return n({
81
+ queryKey: r ? s.signals(r) : [
82
+ "buildmyiq",
83
+ "proctoring",
84
+ "signals",
85
+ "nil"
86
+ ],
87
+ enabled: !!r && i,
88
+ initialPageParam: void 0,
89
+ queryFn: async ({ pageParam: e }) => (await t(`query BuiProctoringSessionSignals($sessionId: ID!, $limit: Int, $after: ID) {
90
+ buiProctoringSessionSignals(sessionId: $sessionId, limit: $limit, after: $after) {
91
+ items { ${l} }
92
+ pageInfo { cursor hasMore }
93
+ }
94
+ }`, e ? {
95
+ sessionId: r,
96
+ limit: 100,
97
+ after: e
98
+ } : {
99
+ sessionId: r,
100
+ limit: 100
101
+ }, a)).buiProctoringSessionSignals,
102
+ getNextPageParam: (e) => e.pageInfo.hasMore ? e.pageInfo.cursor ?? void 0 : void 0
103
+ });
104
+ }, g = (n, r = !0) => {
105
+ let { apiGatewayUrl: a } = e(), { status: c, assessmentId: l, limit: u = 50 } = n;
106
+ return i({
75
107
  queryKey: [
76
- ...o.queue,
108
+ ...s.queue,
77
109
  c ?? "ALL",
78
110
  l ?? "ALL",
79
111
  u
80
112
  ],
81
- enabled: i,
113
+ enabled: r,
82
114
  queryFn: async () => (await t(`query BuiListProctoringSessions($assessmentId: ID, $status: BuiProctoringStatus, $pagination: PaginationInput) {
83
115
  buiListProctoringSessions(assessmentId: $assessmentId, status: $status, pagination: $pagination) {
84
- items { ${a} }
116
+ items { ${o} }
85
117
  pageInfo { cursor hasMore }
86
118
  }
87
119
  }`, {
88
120
  assessmentId: l,
89
121
  status: c,
90
122
  pagination: { limit: u }
91
- }, s)).buiListProctoringSessions
123
+ }, a)).buiListProctoringSessions
92
124
  });
93
- }, m = (n, i = !0) => {
125
+ }, _ = (n, r = !0) => {
94
126
  let { apiGatewayUrl: a } = e();
95
- return r({
96
- queryKey: o.identityFile(n ?? "nil"),
97
- enabled: !!n && i,
98
- staleTime: s,
99
- refetchInterval: s,
127
+ return i({
128
+ queryKey: s.identityFile(n ?? "nil"),
129
+ enabled: !!n && r,
130
+ staleTime: c,
131
+ refetchInterval: c,
100
132
  retry: !1,
101
133
  queryFn: async () => (await t("query BuiProctoringIdentityFile($sessionId: ID!) {\n buiProctoringIdentityFile(sessionId: $sessionId) {\n fileId name mimeType size downloadUrl expiresAt\n }\n }", { sessionId: n }, a)).buiProctoringIdentityFile
102
134
  });
103
- }, h = () => {
104
- let { apiGatewayUrl: r } = e(), s = i();
105
- return n({
135
+ }, v = () => {
136
+ let { apiGatewayUrl: n } = e(), i = a();
137
+ return r({
106
138
  mutationFn: async (e) => (await t(`mutation BuiReviewProctoringSession($id: ID!, $input: BuiReviewProctoringSessionInput!) {
107
- buiReviewProctoringSession(id: $id, input: $input) { ${a} }
108
- }`, e, r)).buiReviewProctoringSession,
109
- onSuccess: () => s.invalidateQueries({ queryKey: o.queue })
139
+ buiReviewProctoringSession(id: $id, input: $input) { ${o} }
140
+ }`, e, n)).buiReviewProctoringSession,
141
+ onSuccess: () => i.invalidateQueries({ queryKey: s.queue })
110
142
  });
111
143
  };
112
144
  //#endregion
113
- export { l as useBuiMyProctoringSession, m as useBuiProctoringIdentityFile, p as useBuiProctoringSessions, f as useBuiReportProctoringViolation, h as useBuiReviewProctoringSession, u as useBuiStartProctoringSession, d as useBuiSubmitProctoringIdentity };
145
+ export { d as useBuiMyProctoringSession, _ as useBuiProctoringIdentityFile, h as useBuiProctoringSessionSignals, g as useBuiProctoringSessions, m as useBuiRecordProctoringSignals, v as useBuiReviewProctoringSession, f as useBuiStartProctoringSession, p as useBuiSubmitProctoringIdentity };
114
146
 
115
147
  //# sourceMappingURL=useBuiProctoring.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useBuiProctoring.js","names":[],"sources":["../../../src/buildmyiq/hooks/useBuiProctoring.ts"],"sourcesContent":["/**\n * Buildmyiq — high-stakes exam proctoring hooks (BOFF-4431).\n * @module buildmyiq/hooks/useBuiProctoring\n *\n * Wired to the `wspace-buildmyiq-svc` proctoring contract:\n * - learner (self-scoped): `buiMyProctoringSession`, `buiStartProctoringSession`,\n * `buiSubmitProctoringIdentity`, `buiReportProctoringViolation`\n * - reviewer (workspace-scoped): `buiListProctoringSessions`,\n * `buiReviewProctoringSession`, `buiProctoringIdentityFile`\n *\n * The backend is the gate: `buiSubmitAssessment` refuses a `requiresProctoring`\n * assessment unless the learner's latest session is APPROVED and unexpired.\n * These hooks only drive the UI through that lifecycle.\n */\n\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\n\nimport { useBuildmyiq } from '../context';\nimport { wspaceFetch } from './useBuiFiles';\nimport type {\n BuiConnection,\n BuiProctoringIdentityFile,\n BuiProctoringSession,\n BuiProctoringStatus,\n BuiReviewProctoringSessionInput,\n BuiSubmitProctoringIdentityInput,\n} from '../types';\n\n// `identityFile` selects ONLY `id`: wspace-files-svc's File entity has no\n// __resolveReference, so any other field on a federated File stub fails the\n// whole operation. The learner's photo ID is PRIVATE in the file store\n// (BOFF-9691), so the reviewer page opens it through `buiProctoringIdentityFile`\n// (useBuiProctoringIdentityFile), never through the files graph.\nconst PROCTORING_SESSION_FIELDS = `\n id workspaceId assessmentId learnerId status lockdownAcknowledgedAt violationCount\n reviewedById reviewedAt reviewNotes expiresAt createdAt updatedAt\n identityFile { id }\n`;\n\nconst PROCTORING_KEY = {\n mine: (assessmentId: string) => ['buildmyiq', 'proctoring', 'mine', assessmentId] as const,\n queue: ['buildmyiq', 'proctoring', 'queue'] as const,\n identityFile: (sessionId: string) =>\n ['buildmyiq', 'proctoring', 'identity-file', sessionId] as const,\n};\n\n/**\n * The backend presigns the identity URL for about five minutes; re-fetch well\n * before that so an open review page never holds a dead link.\n */\nconst IDENTITY_URL_REFRESH_MS = 4 * 60_000;\n\n/** How often the learner's waiting screen re-checks for a reviewer decision. */\nconst AWAITING_REVIEW_POLL_MS = 15_000;\n\n/**\n * The calling learner's most recent proctoring session for an assessment\n * (null when they have never started one). Polls while a reviewer decision\n * is pending so the exam unlocks without a manual refresh.\n */\nexport const useBuiMyProctoringSession = (assessmentId: string | undefined, enabled = true) => {\n const { apiGatewayUrl } = useBuildmyiq();\n return useQuery({\n queryKey: assessmentId\n ? PROCTORING_KEY.mine(assessmentId)\n : ['buildmyiq', 'proctoring', 'mine', 'nil'],\n enabled: Boolean(assessmentId) && enabled,\n queryFn: async () => {\n const data = await wspaceFetch<{ buiMyProctoringSession: BuiProctoringSession | null }>(\n `query BuiMyProctoringSession($assessmentId: ID!) {\n buiMyProctoringSession(assessmentId: $assessmentId) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n { assessmentId },\n apiGatewayUrl\n );\n return data.buiMyProctoringSession;\n },\n refetchInterval: (query) => {\n const status = query.state.data?.status;\n return status === 'UNDER_REVIEW' || status === 'IDENTITY_SUBMITTED'\n ? AWAITING_REVIEW_POLL_MS\n : false;\n },\n });\n};\n\nexport const useBuiStartProctoringSession = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (assessmentId: string) => {\n const data = await wspaceFetch<{ buiStartProctoringSession: BuiProctoringSession }>(\n `mutation BuiStartProctoringSession($assessmentId: ID!) {\n buiStartProctoringSession(assessmentId: $assessmentId) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n { assessmentId },\n apiGatewayUrl\n );\n return data.buiStartProctoringSession;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n },\n });\n};\n\nexport const useBuiSubmitProctoringIdentity = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (args: { id: string; input: BuiSubmitProctoringIdentityInput }) => {\n const data = await wspaceFetch<{ buiSubmitProctoringIdentity: BuiProctoringSession }>(\n `mutation BuiSubmitProctoringIdentity($id: ID!, $input: BuiSubmitProctoringIdentityInput!) {\n buiSubmitProctoringIdentity(id: $id, input: $input) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n args,\n apiGatewayUrl\n );\n return data.buiSubmitProctoringIdentity;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n void qc.invalidateQueries({ queryKey: PROCTORING_KEY.queue });\n },\n });\n};\n\n/** Records one lockdown violation (tab switch, window blur, fullscreen exit, copy/paste). */\nexport const useBuiReportProctoringViolation = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (id: string) => {\n const data = await wspaceFetch<{ buiReportProctoringViolation: BuiProctoringSession }>(\n `mutation BuiReportProctoringViolation($id: ID!) {\n buiReportProctoringViolation(id: $id) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n { id },\n apiGatewayUrl\n );\n return data.buiReportProctoringViolation;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n },\n });\n};\n\n/** Reviewer queue: proctoring sessions in the workspace, optionally filtered by status. */\nexport const useBuiProctoringSessions = (\n args: { status?: BuiProctoringStatus; assessmentId?: string; limit?: number },\n enabled = true\n) => {\n const { apiGatewayUrl } = useBuildmyiq();\n const { status, assessmentId, limit = 50 } = args;\n return useQuery({\n queryKey: [...PROCTORING_KEY.queue, status ?? 'ALL', assessmentId ?? 'ALL', limit],\n enabled,\n queryFn: async () => {\n const data = await wspaceFetch<{\n buiListProctoringSessions: BuiConnection<BuiProctoringSession>;\n }>(\n `query BuiListProctoringSessions($assessmentId: ID, $status: BuiProctoringStatus, $pagination: PaginationInput) {\n buiListProctoringSessions(assessmentId: $assessmentId, status: $status, pagination: $pagination) {\n items { ${PROCTORING_SESSION_FIELDS} }\n pageInfo { cursor hasMore }\n }\n }`,\n { assessmentId, status, pagination: { limit } },\n apiGatewayUrl\n );\n return data.buiListProctoringSessions;\n },\n });\n};\n\n/**\n * Reviewer-only: the learner's identity photo/ID for one session, with a\n * short-lived download URL (BOFF-9691). Null when the session has none.\n */\nexport const useBuiProctoringIdentityFile = (sessionId: string | undefined, enabled = true) => {\n const { apiGatewayUrl } = useBuildmyiq();\n return useQuery({\n queryKey: PROCTORING_KEY.identityFile(sessionId ?? 'nil'),\n enabled: Boolean(sessionId) && enabled,\n staleTime: IDENTITY_URL_REFRESH_MS,\n refetchInterval: IDENTITY_URL_REFRESH_MS,\n retry: false,\n queryFn: async () => {\n const data = await wspaceFetch<{\n buiProctoringIdentityFile: BuiProctoringIdentityFile | null;\n }>(\n `query BuiProctoringIdentityFile($sessionId: ID!) {\n buiProctoringIdentityFile(sessionId: $sessionId) {\n fileId name mimeType size downloadUrl expiresAt\n }\n }`,\n { sessionId },\n apiGatewayUrl\n );\n return data.buiProctoringIdentityFile;\n },\n });\n};\n\nexport const useBuiReviewProctoringSession = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (args: { id: string; input: BuiReviewProctoringSessionInput }) => {\n const data = await wspaceFetch<{ buiReviewProctoringSession: BuiProctoringSession }>(\n `mutation BuiReviewProctoringSession($id: ID!, $input: BuiReviewProctoringSessionInput!) {\n buiReviewProctoringSession(id: $id, input: $input) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n args,\n apiGatewayUrl\n );\n return data.buiReviewProctoringSession;\n },\n onSuccess: () => qc.invalidateQueries({ queryKey: PROCTORING_KEY.queue }),\n });\n};\n"],"mappings":";;;;;AAiCA,IAAM,IAA4B,wLAM5B,IAAiB;CACrB,OAAO,MAAyB;EAAC;EAAa;EAAc;EAAQ;CAAY;CAChF,OAAO;EAAC;EAAa;EAAc;CAAO;CAC1C,eAAe,MACb;EAAC;EAAa;EAAc;EAAiB;CAAS;AAC1D,GAMM,IAA0B,IAAI,KAG9B,IAA0B,MAOnB,KAA6B,GAAkC,IAAU,OAAS;CAC7F,IAAM,EAAE,qBAAkB,EAAa;CACvC,OAAO,EAAS;EACd,UAAU,IACN,EAAe,KAAK,CAAY,IAChC;GAAC;GAAa;GAAc;GAAQ;EAAK;EAC7C,SAAS,EAAQ,KAAiB;EAClC,SAAS,aAQA,MAPY,EACjB;kEAC0D,EAA0B;YAEpF,EAAE,gBAAa,GACf,CACF,GACY;EAEd,kBAAkB,MAAU;GAC1B,IAAM,IAAS,EAAM,MAAM,MAAM;GACjC,OAAO,MAAW,kBAAkB,MAAW,uBAC3C,IACA;EACN;CACF,CAAC;AACH,GAEa,UAAqC;CAChD,IAAM,EAAE,qBAAkB,EAAa,GACjC,IAAK,EAAe;CAC1B,OAAO,EAAY;EACjB,YAAY,OAAO,OAQV,MAPY,EACjB;qEAC6D,EAA0B;YAEvF,EAAE,gBAAa,GACf,CACF,GACY;EAEd,YAAY,MAAY;GACtB,EAAG,aAAa,EAAe,KAAK,EAAQ,YAAY,GAAG,CAAO;EACpE;CACF,CAAC;AACH,GAEa,UAAuC;CAClD,IAAM,EAAE,qBAAkB,EAAa,GACjC,IAAK,EAAe;CAC1B,OAAO,EAAY;EACjB,YAAY,OAAO,OAQV,MAPY,EACjB;kEAC0D,EAA0B;YAEpF,GACA,CACF,GACY;EAEd,YAAY,MAAY;GAEtB,AADA,EAAG,aAAa,EAAe,KAAK,EAAQ,YAAY,GAAG,CAAO,GAClE,EAAQ,kBAAkB,EAAE,UAAU,EAAe,MAAM,CAAC;EAC9D;CACF,CAAC;AACH,GAGa,UAAwC;CACnD,IAAM,EAAE,qBAAkB,EAAa,GACjC,IAAK,EAAe;CAC1B,OAAO,EAAY;EACjB,YAAY,OAAO,OAQV,MAPY,EACjB;oDAC4C,EAA0B;YAEtE,EAAE,MAAG,GACL,CACF,GACY;EAEd,YAAY,MAAY;GACtB,EAAG,aAAa,EAAe,KAAK,EAAQ,YAAY,GAAG,CAAO;EACpE;CACF,CAAC;AACH,GAGa,KACX,GACA,IAAU,OACP;CACH,IAAM,EAAE,qBAAkB,EAAa,GACjC,EAAE,WAAQ,iBAAc,WAAQ,OAAO;CAC7C,OAAO,EAAS;EACd,UAAU;GAAC,GAAG,EAAe;GAAO,KAAU;GAAO,KAAgB;GAAO;EAAK;EACjF;EACA,SAAS,aAaA,MAZY,EAGjB;;sBAEc,EAA0B;;;YAIxC;GAAE;GAAc;GAAQ,YAAY,EAAE,SAAM;EAAE,GAC9C,CACF,GACY;CAEhB,CAAC;AACH,GAMa,KAAgC,GAA+B,IAAU,OAAS;CAC7F,IAAM,EAAE,qBAAkB,EAAa;CACvC,OAAO,EAAS;EACd,UAAU,EAAe,aAAa,KAAa,KAAK;EACxD,SAAS,EAAQ,KAAc;EAC/B,WAAW;EACX,iBAAiB;EACjB,OAAO;EACP,SAAS,aAYA,MAXY,EAGjB,yMAKA,EAAE,aAAU,GACZ,CACF,GACY;CAEhB,CAAC;AACH,GAEa,UAAsC;CACjD,IAAM,EAAE,qBAAkB,EAAa,GACjC,IAAK,EAAe;CAC1B,OAAO,EAAY;EACjB,YAAY,OAAO,OAQV,MAPY,EACjB;iEACyD,EAA0B;YAEnF,GACA,CACF,GACY;EAEd,iBAAiB,EAAG,kBAAkB,EAAE,UAAU,EAAe,MAAM,CAAC;CAC1E,CAAC;AACH"}
1
+ {"version":3,"file":"useBuiProctoring.js","names":[],"sources":["../../../src/buildmyiq/hooks/useBuiProctoring.ts"],"sourcesContent":["/**\n * Buildmyiq — high-stakes exam proctoring hooks (BOFF-4431).\n * @module buildmyiq/hooks/useBuiProctoring\n *\n * Wired to the `wspace-buildmyiq-svc` proctoring contract:\n * - learner (self-scoped): `buiMyProctoringSession`, `buiStartProctoringSession`,\n * `buiSubmitProctoringIdentity`, `buiReportProctoringViolation`\n * - reviewer (workspace-scoped): `buiListProctoringSessions`,\n * `buiReviewProctoringSession`, `buiProctoringIdentityFile`\n * - BOFF-9690 signals: `buiRecordProctoringSignals` (learner, own session)\n * and the reviewer timeline `buiProctoringSessionSignals`\n *\n * The backend is the gate: `buiSubmitAssessment` refuses a `requiresProctoring`\n * assessment unless the learner's latest session is APPROVED and unexpired.\n * These hooks only drive the UI through that lifecycle.\n */\n\nimport { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\n\nimport { useBuildmyiq } from '../context';\nimport { wspaceFetch } from './useBuiFiles';\nimport type {\n BuiConnection,\n BuiProctoringIdentityFile,\n BuiProctoringSession,\n BuiProctoringSignal,\n BuiProctoringSignalInput,\n BuiProctoringStatus,\n BuiReviewProctoringSessionInput,\n BuiSubmitProctoringIdentityInput,\n} from '../types';\n\n// `identityFile` selects ONLY `id`: wspace-files-svc's File entity has no\n// __resolveReference, so any other field on a federated File stub fails the\n// whole operation. The learner's photo ID is PRIVATE in the file store\n// (BOFF-9691), so the reviewer page opens it through `buiProctoringIdentityFile`\n// (useBuiProctoringIdentityFile), never through the files graph.\nconst PROCTORING_SESSION_FIELDS = `\n id workspaceId assessmentId learnerId status lockdownAcknowledgedAt violationCount\n aiMonitoringConsentAt signalCount reviewedById reviewedAt reviewNotes expiresAt createdAt updatedAt\n identityFile { id }\n`;\n\nconst PROCTORING_KEY = {\n mine: (assessmentId: string) => ['buildmyiq', 'proctoring', 'mine', assessmentId] as const,\n queue: ['buildmyiq', 'proctoring', 'queue'] as const,\n identityFile: (sessionId: string) =>\n ['buildmyiq', 'proctoring', 'identity-file', sessionId] as const,\n signals: (sessionId: string) => ['buildmyiq', 'proctoring', 'signals', sessionId] as const,\n};\n\n/**\n * The backend presigns the identity URL for about five minutes; re-fetch well\n * before that so an open review page never holds a dead link.\n */\nconst IDENTITY_URL_REFRESH_MS = 4 * 60_000;\n\nconst PROCTORING_SIGNAL_FIELDS = `\n id sessionId type startedAt durationMs confidence faceCount peakLevelDb createdAt\n`;\n\n/** How often the learner's waiting screen re-checks for a reviewer decision. */\nconst AWAITING_REVIEW_POLL_MS = 15_000;\n\n/**\n * The calling learner's most recent proctoring session for an assessment\n * (null when they have never started one). Polls while a reviewer decision\n * is pending so the exam unlocks without a manual refresh.\n */\nexport const useBuiMyProctoringSession = (assessmentId: string | undefined, enabled = true) => {\n const { apiGatewayUrl } = useBuildmyiq();\n return useQuery({\n queryKey: assessmentId\n ? PROCTORING_KEY.mine(assessmentId)\n : ['buildmyiq', 'proctoring', 'mine', 'nil'],\n enabled: Boolean(assessmentId) && enabled,\n queryFn: async () => {\n const data = await wspaceFetch<{ buiMyProctoringSession: BuiProctoringSession | null }>(\n `query BuiMyProctoringSession($assessmentId: ID!) {\n buiMyProctoringSession(assessmentId: $assessmentId) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n { assessmentId },\n apiGatewayUrl\n );\n return data.buiMyProctoringSession;\n },\n refetchInterval: (query) => {\n const status = query.state.data?.status;\n return status === 'UNDER_REVIEW' || status === 'IDENTITY_SUBMITTED'\n ? AWAITING_REVIEW_POLL_MS\n : false;\n },\n });\n};\n\nexport const useBuiStartProctoringSession = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (assessmentId: string) => {\n const data = await wspaceFetch<{ buiStartProctoringSession: BuiProctoringSession }>(\n `mutation BuiStartProctoringSession($assessmentId: ID!) {\n buiStartProctoringSession(assessmentId: $assessmentId) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n { assessmentId },\n apiGatewayUrl\n );\n return data.buiStartProctoringSession;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n },\n });\n};\n\nexport const useBuiSubmitProctoringIdentity = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (args: { id: string; input: BuiSubmitProctoringIdentityInput }) => {\n const data = await wspaceFetch<{ buiSubmitProctoringIdentity: BuiProctoringSession }>(\n `mutation BuiSubmitProctoringIdentity($id: ID!, $input: BuiSubmitProctoringIdentityInput!) {\n buiSubmitProctoringIdentity(id: $id, input: $input) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n args,\n apiGatewayUrl\n );\n return data.buiSubmitProctoringIdentity;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n void qc.invalidateQueries({ queryKey: PROCTORING_KEY.queue });\n },\n });\n};\n\n/** Records one lockdown violation (tab switch, window blur, fullscreen exit, copy/paste). */\nexport const useBuiReportProctoringViolation = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (id: string) => {\n const data = await wspaceFetch<{ buiReportProctoringViolation: BuiProctoringSession }>(\n `mutation BuiReportProctoringViolation($id: ID!) {\n buiReportProctoringViolation(id: $id) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n { id },\n apiGatewayUrl\n );\n return data.buiReportProctoringViolation;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n },\n });\n};\n\n/**\n * BOFF-9690: records a batch (max 50) of signal episodes on the learner's own\n * approved session — lockdown events and, when the assessment opted in and\n * the learner consented, in-browser AI signals.\n */\nexport const useBuiRecordProctoringSignals = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (args: { id: string; signals: BuiProctoringSignalInput[] }) => {\n const data = await wspaceFetch<{ buiRecordProctoringSignals: BuiProctoringSession }>(\n `mutation BuiRecordProctoringSignals($id: ID!, $signals: [BuiProctoringSignalInput!]!) {\n buiRecordProctoringSignals(id: $id, signals: $signals) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n args,\n apiGatewayUrl\n );\n return data.buiRecordProctoringSignals;\n },\n onSuccess: (session) => {\n qc.setQueryData(PROCTORING_KEY.mine(session.assessmentId), session);\n },\n });\n};\n\n/** BOFF-9690: reviewer timeline of one session's signals, oldest first, 100 per page. */\nexport const useBuiProctoringSessionSignals = (sessionId: string | undefined, enabled = true) => {\n const { apiGatewayUrl } = useBuildmyiq();\n return useInfiniteQuery({\n queryKey: sessionId\n ? PROCTORING_KEY.signals(sessionId)\n : ['buildmyiq', 'proctoring', 'signals', 'nil'],\n enabled: Boolean(sessionId) && enabled,\n initialPageParam: undefined as string | undefined,\n queryFn: async ({ pageParam }) => {\n const data = await wspaceFetch<{\n buiProctoringSessionSignals: BuiConnection<BuiProctoringSignal>;\n }>(\n `query BuiProctoringSessionSignals($sessionId: ID!, $limit: Int, $after: ID) {\n buiProctoringSessionSignals(sessionId: $sessionId, limit: $limit, after: $after) {\n items { ${PROCTORING_SIGNAL_FIELDS} }\n pageInfo { cursor hasMore }\n }\n }`,\n // Explicit limit/after: the composed PaginationInput carries only `limit`.\n pageParam ? { sessionId, limit: 100, after: pageParam } : { sessionId, limit: 100 },\n apiGatewayUrl\n );\n return data.buiProctoringSessionSignals;\n },\n getNextPageParam: (last) =>\n last.pageInfo.hasMore ? (last.pageInfo.cursor ?? undefined) : undefined,\n });\n};\n\n/** Reviewer queue: proctoring sessions in the workspace, optionally filtered by status. */\nexport const useBuiProctoringSessions = (\n args: { status?: BuiProctoringStatus; assessmentId?: string; limit?: number },\n enabled = true\n) => {\n const { apiGatewayUrl } = useBuildmyiq();\n const { status, assessmentId, limit = 50 } = args;\n return useQuery({\n queryKey: [...PROCTORING_KEY.queue, status ?? 'ALL', assessmentId ?? 'ALL', limit],\n enabled,\n queryFn: async () => {\n const data = await wspaceFetch<{\n buiListProctoringSessions: BuiConnection<BuiProctoringSession>;\n }>(\n `query BuiListProctoringSessions($assessmentId: ID, $status: BuiProctoringStatus, $pagination: PaginationInput) {\n buiListProctoringSessions(assessmentId: $assessmentId, status: $status, pagination: $pagination) {\n items { ${PROCTORING_SESSION_FIELDS} }\n pageInfo { cursor hasMore }\n }\n }`,\n { assessmentId, status, pagination: { limit } },\n apiGatewayUrl\n );\n return data.buiListProctoringSessions;\n },\n });\n};\n\n/**\n * Reviewer-only: the learner's identity photo/ID for one session, with a\n * short-lived download URL (BOFF-9691). Null when the session has none.\n */\nexport const useBuiProctoringIdentityFile = (sessionId: string | undefined, enabled = true) => {\n const { apiGatewayUrl } = useBuildmyiq();\n return useQuery({\n queryKey: PROCTORING_KEY.identityFile(sessionId ?? 'nil'),\n enabled: Boolean(sessionId) && enabled,\n staleTime: IDENTITY_URL_REFRESH_MS,\n refetchInterval: IDENTITY_URL_REFRESH_MS,\n retry: false,\n queryFn: async () => {\n const data = await wspaceFetch<{\n buiProctoringIdentityFile: BuiProctoringIdentityFile | null;\n }>(\n `query BuiProctoringIdentityFile($sessionId: ID!) {\n buiProctoringIdentityFile(sessionId: $sessionId) {\n fileId name mimeType size downloadUrl expiresAt\n }\n }`,\n { sessionId },\n apiGatewayUrl\n );\n return data.buiProctoringIdentityFile;\n },\n });\n};\n\nexport const useBuiReviewProctoringSession = () => {\n const { apiGatewayUrl } = useBuildmyiq();\n const qc = useQueryClient();\n return useMutation({\n mutationFn: async (args: { id: string; input: BuiReviewProctoringSessionInput }) => {\n const data = await wspaceFetch<{ buiReviewProctoringSession: BuiProctoringSession }>(\n `mutation BuiReviewProctoringSession($id: ID!, $input: BuiReviewProctoringSessionInput!) {\n buiReviewProctoringSession(id: $id, input: $input) { ${PROCTORING_SESSION_FIELDS} }\n }`,\n args,\n apiGatewayUrl\n );\n return data.buiReviewProctoringSession;\n },\n onSuccess: () => qc.invalidateQueries({ queryKey: PROCTORING_KEY.queue }),\n });\n};\n"],"mappings":"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"}
@@ -71,6 +71,7 @@ export declare const useBuiAssessments: (courseId: string | undefined, status?:
71
71
  timeLimitMin: number;
72
72
  isGraded: boolean;
73
73
  requiresProctoring: boolean;
74
+ aiProctoringEnabled: boolean;
74
75
  }[]>, Error>;
75
76
  export declare const useBuiAssessment: (id: string | undefined) => import('@tanstack/react-query').UseQueryResult<NoInfer<BuiAssessment | null>, Error>;
76
77
  export declare const useBuiCreateAssessment: () => import('@tanstack/react-query').UseMutationResult<BuiAssessment, Error, BuiCreateAssessmentInput, unknown>;