dsh-diagnostic-tutor 0.1.0 → 0.2.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 +316 -79
- package/lib/api.js +364 -78
- package/lib/api.js.map +1 -1
- package/lib/block-types.js +25 -0
- package/lib/block-types.js.map +1 -0
- package/lib/client.js +9952 -821
- package/lib/client.js.map +1 -1
- package/lib/diagnostics.js +170 -0
- package/lib/diagnostics.js.map +1 -0
- package/lib/execution-key.js +91 -0
- package/lib/execution-key.js.map +1 -0
- package/lib/handoff.js +44 -1
- package/lib/handoff.js.map +1 -1
- package/lib/index.js +87 -51
- package/lib/index.js.map +1 -1
- package/lib/insights.js +82 -0
- package/lib/insights.js.map +1 -0
- package/lib/interactions.js +70 -0
- package/lib/interactions.js.map +1 -0
- package/lib/journal.js +122 -0
- package/lib/journal.js.map +1 -0
- package/lib/lesson-markdown.js +72 -0
- package/lib/lesson-markdown.js.map +1 -0
- package/lib/lesson.js +152 -3
- package/lib/lesson.js.map +1 -1
- package/lib/math.js +126 -0
- package/lib/math.js.map +1 -0
- package/lib/path-api.js +140 -0
- package/lib/path-api.js.map +1 -0
- package/lib/path-runtime.js +361 -0
- package/lib/path-runtime.js.map +1 -0
- package/lib/path-tools.js +107 -0
- package/lib/path-tools.js.map +1 -0
- package/lib/path.js +179 -0
- package/lib/path.js.map +1 -0
- package/lib/plan-tools.js +89 -0
- package/lib/plan-tools.js.map +1 -0
- package/lib/plan.js +82 -0
- package/lib/plan.js.map +1 -0
- package/lib/project-session.js +48 -0
- package/lib/project-session.js.map +1 -0
- package/lib/prompt.js +4 -19
- package/lib/prompt.js.map +1 -1
- package/lib/review-sync.js +51 -0
- package/lib/review-sync.js.map +1 -0
- package/lib/review-tools.js +115 -0
- package/lib/review-tools.js.map +1 -0
- package/lib/review.js +103 -0
- package/lib/review.js.map +1 -0
- package/lib/source-schema.js +24 -0
- package/lib/source-schema.js.map +1 -0
- package/lib/source-tools.js +61 -0
- package/lib/source-tools.js.map +1 -0
- package/lib/sources.js +78 -0
- package/lib/sources.js.map +1 -0
- package/lib/state.js +353 -47
- package/lib/state.js.map +1 -1
- package/lib/tools.js +620 -123
- package/lib/tools.js.map +1 -1
- package/lib/types/api.d.ts +10 -3
- package/lib/types/api.d.ts.map +1 -1
- package/lib/types/block-types.d.ts +12 -0
- package/lib/types/block-types.d.ts.map +1 -0
- package/lib/types/contract.d.ts +81 -7
- package/lib/types/contract.d.ts.map +1 -1
- package/lib/types/diagnostics.d.ts +95 -0
- package/lib/types/diagnostics.d.ts.map +1 -0
- package/lib/types/execution-key.d.ts +59 -0
- package/lib/types/execution-key.d.ts.map +1 -0
- package/lib/types/handoff.d.ts +2 -2
- package/lib/types/handoff.d.ts.map +1 -1
- package/lib/types/index.d.ts +19 -14
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/insights.d.ts +57 -0
- package/lib/types/insights.d.ts.map +1 -0
- package/lib/types/interactions.d.ts +23 -0
- package/lib/types/interactions.d.ts.map +1 -0
- package/lib/types/journal.d.ts +57 -0
- package/lib/types/journal.d.ts.map +1 -0
- package/lib/types/lesson-markdown.d.ts +11 -0
- package/lib/types/lesson-markdown.d.ts.map +1 -0
- package/lib/types/lesson.d.ts +697 -5
- package/lib/types/lesson.d.ts.map +1 -1
- package/lib/types/math.d.ts +50 -0
- package/lib/types/math.d.ts.map +1 -0
- package/lib/types/path-api.d.ts +5 -0
- package/lib/types/path-api.d.ts.map +1 -0
- package/lib/types/path-runtime.d.ts +1558 -0
- package/lib/types/path-runtime.d.ts.map +1 -0
- package/lib/types/path-tools.d.ts +4 -0
- package/lib/types/path-tools.d.ts.map +1 -0
- package/lib/types/path.d.ts +744 -0
- package/lib/types/path.d.ts.map +1 -0
- package/lib/types/plan-tools.d.ts +4 -0
- package/lib/types/plan-tools.d.ts.map +1 -0
- package/lib/types/plan.d.ts +53 -0
- package/lib/types/plan.d.ts.map +1 -0
- package/lib/types/project-session.d.ts +33 -0
- package/lib/types/project-session.d.ts.map +1 -0
- package/lib/types/prompt.d.ts.map +1 -1
- package/lib/types/review-sync.d.ts +17 -0
- package/lib/types/review-sync.d.ts.map +1 -0
- package/lib/types/review-tools.d.ts +4 -0
- package/lib/types/review-tools.d.ts.map +1 -0
- package/lib/types/review.d.ts +65 -0
- package/lib/types/review.d.ts.map +1 -0
- package/lib/types/source-schema.d.ts +31 -0
- package/lib/types/source-schema.d.ts.map +1 -0
- package/lib/types/source-tools.d.ts +4 -0
- package/lib/types/source-tools.d.ts.map +1 -0
- package/lib/types/sources.d.ts +33 -0
- package/lib/types/sources.d.ts.map +1 -0
- package/lib/types/state.d.ts +607 -23
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/tools.d.ts +3 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/udt.d.ts +20 -24
- package/lib/types/udt.d.ts.map +1 -1
- package/lib/types/version.d.ts +11 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/vocabulary.d.ts +2 -2
- package/lib/udt.js +72 -19
- package/lib/udt.js.map +1 -1
- package/lib/version.js +26 -0
- package/lib/version.js.map +1 -0
- package/package.json +27 -22
package/lib/api.js
CHANGED
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { beginHandoff, handoffView, withFocusRecorded, withObserved, withPromptFailure, withPrompted, } from './handoff.js';
|
|
20
20
|
import { focusPromptText } from './prompt.js';
|
|
21
|
+
import { resolveLearningSession } from './project-session.js';
|
|
22
|
+
import { buildDiagnostics } from './diagnostics.js';
|
|
21
23
|
import { guarded } from './trust-fence.js';
|
|
24
|
+
import { handlePathApi } from './path-api.js';
|
|
25
|
+
import { buildIcs, sortTasks, taskView } from './plan.js';
|
|
26
|
+
import { buildInsights } from './insights.js';
|
|
27
|
+
import { REVIEW_RATINGS, dueCards, scheduleReview } from './review.js';
|
|
22
28
|
/** Largest request body accepted, in bytes. */
|
|
23
29
|
export const MAX_BODY_BYTES = 64 * 1024;
|
|
24
30
|
/** Route prefix owned by this plugin. */
|
|
@@ -102,24 +108,122 @@ function stringField(body, key) {
|
|
|
102
108
|
/* Handler */
|
|
103
109
|
/* -------------------------------------------------------------------------- */
|
|
104
110
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
111
|
+
* The focus this conversation is working on, or nothing.
|
|
112
|
+
*
|
|
113
|
+
* Learning state belongs to a conversation, not to a course. The course and its
|
|
114
|
+
* map and its lessons are shared knowledge and stay visible to everyone; where
|
|
115
|
+
* *this* conversation has got to is not. A focus carrying a different
|
|
116
|
+
* conversation's id — or none at all, which means it predates the field — is
|
|
117
|
+
* not this panel's to show, so it reports nothing rather than someone else's
|
|
118
|
+
* lesson under the learner's chat.
|
|
119
|
+
*
|
|
120
|
+
* @param state - the runtime.
|
|
121
|
+
* @param courseId - the course being viewed.
|
|
122
|
+
* @param sessionId - the conversation asking, when it said.
|
|
123
|
+
* @returns the focus record, or undefined.
|
|
107
124
|
*/
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
function focusBelongingTo(state, courseId, sessionId) {
|
|
126
|
+
// No conversation named means no conversation's state: guessing is what let
|
|
127
|
+
// one conversation's lesson appear under another's chat.
|
|
128
|
+
if (sessionId === undefined || sessionId.length === 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
return state.readFocus(sessionId, courseId);
|
|
131
|
+
}
|
|
132
|
+
/** The project a conversation has open, if any. */
|
|
133
|
+
function projectFor(state, sessionId) {
|
|
134
|
+
return state.resolveProject(sessionId).course;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every Learning Project, with the numbers the library shows on a card.
|
|
138
|
+
*
|
|
139
|
+
* Learner-level, like the maps and lessons themselves: this is what the
|
|
140
|
+
* learner has, not what this conversation is doing with it.
|
|
141
|
+
*/
|
|
142
|
+
function libraryOf(state) {
|
|
115
143
|
return state
|
|
116
144
|
.listCourses()
|
|
117
|
-
.
|
|
118
|
-
|
|
145
|
+
.map((course) => {
|
|
146
|
+
const nodes = state.listNodes(course.id);
|
|
147
|
+
const learningPath = state.readPath(course.id);
|
|
148
|
+
const status = learningPath?.acceptedVersion != null
|
|
149
|
+
? 'accepted'
|
|
150
|
+
: learningPath !== undefined && (learningPath.brief !== null || learningPath.assessment !== null || learningPath.curricula.length > 0)
|
|
151
|
+
? 'active'
|
|
152
|
+
: 'legacy';
|
|
153
|
+
return {
|
|
154
|
+
id: course.id,
|
|
155
|
+
title: course.title,
|
|
156
|
+
goal: course.goal,
|
|
157
|
+
confirmed: nodes.filter((node) => node.state === 'confirmed').length,
|
|
158
|
+
total: nodes.length,
|
|
159
|
+
updatedAt: course.updatedAt,
|
|
160
|
+
status,
|
|
161
|
+
acceptedVersion: learningPath?.acceptedVersion ?? null,
|
|
162
|
+
};
|
|
163
|
+
})
|
|
164
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
165
|
+
}
|
|
166
|
+
function reviewView(card) {
|
|
167
|
+
return {
|
|
168
|
+
id: card.id, front: card.front, back: card.back, dueAt: card.dueAt,
|
|
169
|
+
...(card.courseId === undefined ? {} : { courseId: card.courseId }),
|
|
170
|
+
...(card.nodeId === undefined ? {} : { nodeId: card.nodeId }),
|
|
171
|
+
...(card.sourceRefs === undefined ? {} : { sourceRefs: card.sourceRefs }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function handleOverview(state, deps, sessionId, res) {
|
|
175
|
+
// Availability belongs to the requesting conversation and can change while
|
|
176
|
+
// the plugin stays loaded. A failed observation is inconclusive.
|
|
177
|
+
let teachingBrain = null;
|
|
178
|
+
try {
|
|
179
|
+
teachingBrain = typeof deps.teachingBrain === 'function'
|
|
180
|
+
? await deps.teachingBrain(sessionId)
|
|
181
|
+
: deps.teachingBrain ?? null;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// The runtime remains readable if discovery is temporarily unavailable.
|
|
185
|
+
}
|
|
186
|
+
const { selection, course } = state.resolveProject(sessionId);
|
|
187
|
+
const library = libraryOf(state);
|
|
188
|
+
if (course === undefined) {
|
|
189
|
+
// No project open in this conversation. That is the library, not an error,
|
|
190
|
+
// and not an empty runtime: the projects it lists are real and shared.
|
|
191
|
+
sendJson(res, 200, {
|
|
192
|
+
ok: true,
|
|
193
|
+
selection,
|
|
194
|
+
course: null,
|
|
195
|
+
nodes: [],
|
|
196
|
+
focus: null,
|
|
197
|
+
nextStep: null,
|
|
198
|
+
handoff: null,
|
|
199
|
+
teachingBrain,
|
|
200
|
+
lessonCount: state.lessonCount(),
|
|
201
|
+
tasks: sortTasks(state.listTasks()).map(taskView),
|
|
202
|
+
library,
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const mine = focusBelongingTo(state, course.id, sessionId);
|
|
207
|
+
sendJson(res, 200, {
|
|
208
|
+
ok: true,
|
|
209
|
+
selection,
|
|
210
|
+
course: courseView(course),
|
|
211
|
+
nodes: state.listNodes(course.id).map(nodeView),
|
|
212
|
+
focus: mine === undefined ? null : focusOf(state, sessionId ?? '', course.id),
|
|
213
|
+
// A decision is attached to a focus, so it follows the same rule.
|
|
214
|
+
nextStep: mine === undefined ? null : nextStepOf(state, sessionId ?? '', course.id),
|
|
215
|
+
teachingBrain,
|
|
216
|
+
// The handoff for whichever node is focused, so a reload or a restart
|
|
217
|
+
// rebuilds the progress line instead of showing a blank wait.
|
|
218
|
+
handoff: mine === undefined ? null : handoffFor(state, sessionId ?? '', mine.nodeId, Date.now()),
|
|
219
|
+
lessonCount: state.lessonCount(),
|
|
220
|
+
tasks: sortTasks(state.listTasks()).map(taskView),
|
|
221
|
+
library,
|
|
222
|
+
});
|
|
119
223
|
}
|
|
120
224
|
/** The active focus for one course, joined with its node, as a view. */
|
|
121
|
-
function focusOf(state, courseId) {
|
|
122
|
-
const focus = state.readFocus(courseId);
|
|
225
|
+
function focusOf(state, sessionId, courseId) {
|
|
226
|
+
const focus = state.readFocus(sessionId, courseId);
|
|
123
227
|
if (focus === undefined || focus.status !== 'active')
|
|
124
228
|
return null;
|
|
125
229
|
const node = state.readNode(focus.nodeId);
|
|
@@ -135,11 +239,11 @@ function focusOf(state, courseId) {
|
|
|
135
239
|
* fresh record without `nextStepId`, so acting on a recommendation clears it
|
|
136
240
|
* without anything having to remember that it did.
|
|
137
241
|
*/
|
|
138
|
-
function nextStepOf(state, courseId) {
|
|
139
|
-
const focus = state.readFocus(courseId);
|
|
242
|
+
function nextStepOf(state, sessionId, courseId) {
|
|
243
|
+
const focus = state.readFocus(sessionId, courseId);
|
|
140
244
|
if (focus?.nextStepId === undefined)
|
|
141
245
|
return null;
|
|
142
|
-
const step = state.readNextStep(focus.nextStepId);
|
|
246
|
+
const step = state.readNextStep(sessionId, focus.nextStepId);
|
|
143
247
|
if (step === undefined)
|
|
144
248
|
return null;
|
|
145
249
|
const from = state.readNode(step.fromNodeId);
|
|
@@ -154,55 +258,63 @@ function nextStepOf(state, courseId) {
|
|
|
154
258
|
createdAt: step.createdAt,
|
|
155
259
|
};
|
|
156
260
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
261
|
+
/**
|
|
262
|
+
* The focus this conversation is working on, or nothing.
|
|
263
|
+
*
|
|
264
|
+
* Learning state belongs to a conversation, not to a course. The course and its
|
|
265
|
+
* map and its lessons are shared knowledge and stay visible to everyone; where
|
|
266
|
+
* *this* conversation has got to is not. A focus carrying a different
|
|
267
|
+
* conversation's id — or none at all, which means it predates the field — is
|
|
268
|
+
* not this panel's to show, so it reports nothing rather than someone else's
|
|
269
|
+
* lesson under the learner's chat.
|
|
270
|
+
*
|
|
271
|
+
* @param state - the runtime.
|
|
272
|
+
* @param courseId - the course being viewed.
|
|
273
|
+
* @param sessionId - the conversation asking, when it said.
|
|
274
|
+
* @returns the focus record, or undefined.
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* `POST /project { sessionId, courseId? }` — choose what this conversation learns.
|
|
278
|
+
*
|
|
279
|
+
* The choice is the conversation's, and that is the whole point of the route:
|
|
280
|
+
* the projects are shared, but opening one is a decision a conversation makes
|
|
281
|
+
* and keeps. Sending no `courseId` selects "start something new", which is a
|
|
282
|
+
* deliberate state — it stops the library being shown and stops any project
|
|
283
|
+
* being opened, until the tutor creates one here.
|
|
284
|
+
*
|
|
285
|
+
* Selecting never fabricates execution state. There is no focus until the
|
|
286
|
+
* learner starts a node, so switching projects shows "no learning focus yet"
|
|
287
|
+
* rather than pretending the previous project's position carried over.
|
|
288
|
+
*/
|
|
289
|
+
async function handleSelectProject(state, req, res) {
|
|
290
|
+
const body = await readJsonBody(req);
|
|
291
|
+
if (body === undefined)
|
|
292
|
+
return fail(res, 400, 'bad-body');
|
|
293
|
+
const sessionId = stringField(body, 'sessionId');
|
|
294
|
+
if (sessionId === undefined)
|
|
295
|
+
return fail(res, 400, 'missing-session-id');
|
|
296
|
+
const courseId = stringField(body, 'courseId');
|
|
297
|
+
if (courseId !== undefined && state.readCourse(courseId) === undefined) {
|
|
298
|
+
return fail(res, 404, 'no-such-course');
|
|
175
299
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
course: courseView(course),
|
|
179
|
-
nodes: state.listNodes(course.id).map(nodeView),
|
|
180
|
-
focus: focusOf(state, course.id),
|
|
181
|
-
nextStep: nextStepOf(state, course.id),
|
|
182
|
-
teachingBrain: deps.teachingBrain ?? null,
|
|
183
|
-
// The handoff for whichever node is focused, so a reload or a restart
|
|
184
|
-
// rebuilds the progress line instead of showing a blank wait.
|
|
185
|
-
handoff: (() => {
|
|
186
|
-
const focus = state.readFocus(course.id);
|
|
187
|
-
return focus === undefined ? null : handoffFor(state, focus.nodeId, Date.now());
|
|
188
|
-
})(),
|
|
189
|
-
lessonCount: state.lessonCount(),
|
|
190
|
-
});
|
|
300
|
+
const record = await state.selectProject(sessionId, courseId, new Date().toISOString());
|
|
301
|
+
sendJson(res, 200, { ok: true, project: record });
|
|
191
302
|
}
|
|
192
303
|
/** `GET /lesson?nodeId=` — the lesson the tutor has written for a node, if any. */
|
|
193
|
-
function handleLesson(state,
|
|
194
|
-
|
|
195
|
-
|
|
304
|
+
function handleLesson(state, nodeId, res) {
|
|
305
|
+
// Resolved from the node, not from the caller's project: a lesson belongs to
|
|
306
|
+
// the learner, and asking for one should not depend on what this conversation
|
|
307
|
+
// happens to have open.
|
|
196
308
|
const node = state.readNode(nodeId);
|
|
197
|
-
if (node === undefined || node.courseId
|
|
309
|
+
if (node === undefined || state.readCourse(node.courseId) === undefined) {
|
|
198
310
|
return fail(res, 404, 'no-such-node');
|
|
311
|
+
}
|
|
199
312
|
sendJson(res, 200, { ok: true, lesson: state.lessonForNode(nodeId) ?? null });
|
|
200
313
|
}
|
|
201
|
-
function handleNode(state,
|
|
202
|
-
if (course === undefined)
|
|
203
|
-
return fail(res, 404, 'no-course');
|
|
314
|
+
function handleNode(state, nodeId, res) {
|
|
204
315
|
const node = state.readNode(nodeId);
|
|
205
|
-
|
|
316
|
+
const course = node === undefined ? undefined : state.readCourse(node.courseId);
|
|
317
|
+
if (node === undefined || course === undefined)
|
|
206
318
|
return fail(res, 404, 'no-such-node');
|
|
207
319
|
const parent = node.parentId === undefined ? undefined : state.readNode(node.parentId);
|
|
208
320
|
const children = state
|
|
@@ -229,8 +341,8 @@ function handleNode(state, course, nodeId, res) {
|
|
|
229
341
|
/** How long a handoff is left alone before another request is treated as a retry. */
|
|
230
342
|
const HANDOFF_DEDUPE_MS = 3000;
|
|
231
343
|
/** Build the handoff view, which is what a surface renders. */
|
|
232
|
-
function handoffFor(state, nodeId, nowMs) {
|
|
233
|
-
const record = state.readHandoff(nodeId);
|
|
344
|
+
function handoffFor(state, sessionId, nodeId, nowMs) {
|
|
345
|
+
const record = state.readHandoff(sessionId, nodeId);
|
|
234
346
|
if (record === undefined)
|
|
235
347
|
return null;
|
|
236
348
|
return handoffView(record, nowMs, state.lessonForNode(nodeId) !== undefined);
|
|
@@ -251,9 +363,7 @@ function handoffFor(state, nodeId, nowMs) {
|
|
|
251
363
|
* returns it untouched, which is what makes a double click harmless; a request
|
|
252
364
|
* after it has gone quiet is a retry and bumps the attempt count.
|
|
253
365
|
*/
|
|
254
|
-
async function handleStartFocus(state, deps,
|
|
255
|
-
if (course === undefined)
|
|
256
|
-
return fail(res, 404, 'no-course');
|
|
366
|
+
async function handleStartFocus(state, deps, req, res) {
|
|
257
367
|
const body = await readJsonBody(req);
|
|
258
368
|
if (body === undefined)
|
|
259
369
|
return fail(res, 400, 'bad-body');
|
|
@@ -261,38 +371,51 @@ async function handleStartFocus(state, deps, course, req, res) {
|
|
|
261
371
|
if (nodeId === undefined)
|
|
262
372
|
return fail(res, 400, 'missing-node-id');
|
|
263
373
|
const sessionId = stringField(body, 'sessionId');
|
|
374
|
+
// Required: a focus that cannot say whose it is cannot be shown to anyone,
|
|
375
|
+
// so accepting one would create state nobody could ever see.
|
|
376
|
+
if (sessionId === undefined)
|
|
377
|
+
return fail(res, 400, 'missing-session-id');
|
|
378
|
+
// The course comes from the NODE, not from what this conversation had open.
|
|
379
|
+
// Starting a node in a project is choosing that project — the other
|
|
380
|
+
// direction is the one that must not happen, and does not: selecting a
|
|
381
|
+
// project never invents a focus.
|
|
264
382
|
const node = state.readNode(nodeId);
|
|
265
|
-
|
|
383
|
+
const course = node === undefined ? undefined : state.readCourse(node.courseId);
|
|
384
|
+
if (node === undefined || course === undefined)
|
|
266
385
|
return fail(res, 404, 'no-such-node');
|
|
386
|
+
const chosen = state.readProject(sessionId);
|
|
387
|
+
if (chosen?.courseId !== course.id) {
|
|
388
|
+
await state.selectProject(sessionId, course.id, new Date().toISOString());
|
|
389
|
+
}
|
|
267
390
|
const now = new Date();
|
|
268
|
-
const previous = state.readHandoff(nodeId);
|
|
391
|
+
const previous = state.readHandoff(sessionId, nodeId);
|
|
269
392
|
// Double-click protection: the same target, still in flight and recent, is
|
|
270
393
|
// the same handoff. Not an error — just nothing new to do.
|
|
271
394
|
if (previous !== undefined &&
|
|
272
395
|
previous.status !== 'failed' &&
|
|
273
396
|
now.getTime() - Date.parse(previous.requestedAt) < HANDOFF_DEDUPE_MS) {
|
|
274
|
-
const focus = state.readFocus(course.id);
|
|
397
|
+
const focus = state.readFocus(sessionId, course.id);
|
|
275
398
|
sendJson(res, 200, {
|
|
276
399
|
ok: true,
|
|
277
400
|
focus: focus === undefined ? null : focusView(focus, node),
|
|
278
401
|
prompted: previous.promptedAt !== undefined,
|
|
279
|
-
handoff: handoffFor(state, nodeId, now.getTime()),
|
|
402
|
+
handoff: handoffFor(state, sessionId, nodeId, now.getTime()),
|
|
280
403
|
deduped: true,
|
|
281
404
|
});
|
|
282
405
|
return;
|
|
283
406
|
}
|
|
284
407
|
let handoff = beginHandoff({
|
|
285
408
|
courseId: course.id,
|
|
286
|
-
fromNodeId: previous?.fromNodeId ?? state.readFocus(course.id)?.nodeId ?? nodeId,
|
|
409
|
+
fromNodeId: previous?.fromNodeId ?? state.readFocus(sessionId, course.id)?.nodeId ?? nodeId,
|
|
287
410
|
targetNodeId: nodeId,
|
|
288
411
|
now: now.toISOString(),
|
|
289
|
-
|
|
412
|
+
sessionId,
|
|
290
413
|
...(previous === undefined ? {} : { previous }),
|
|
291
414
|
});
|
|
292
415
|
await state.writeHandoff(handoff);
|
|
293
416
|
let focus;
|
|
294
417
|
try {
|
|
295
|
-
focus = await state.startFocus(course.id, nodeId, now.toISOString());
|
|
418
|
+
focus = await state.startFocus(course.id, nodeId, now.toISOString(), sessionId);
|
|
296
419
|
}
|
|
297
420
|
catch (error) {
|
|
298
421
|
// Covers an unknown node and a node from another course alike.
|
|
@@ -310,7 +433,7 @@ async function handleStartFocus(state, deps, course, req, res) {
|
|
|
310
433
|
focus: focusView(focus, node),
|
|
311
434
|
prompted: outcome.prompted,
|
|
312
435
|
...(outcome.reason === undefined ? {} : { promptReason: outcome.reason }),
|
|
313
|
-
handoff: handoffFor(state, nodeId, Date.now()),
|
|
436
|
+
handoff: handoffFor(state, sessionId, nodeId, Date.now()),
|
|
314
437
|
});
|
|
315
438
|
}
|
|
316
439
|
/**
|
|
@@ -350,11 +473,68 @@ async function handleObserved(state, req, res) {
|
|
|
350
473
|
const nodeId = stringField(body, 'nodeId');
|
|
351
474
|
if (nodeId === undefined)
|
|
352
475
|
return fail(res, 400, 'missing-node-id');
|
|
353
|
-
const
|
|
476
|
+
const sessionId = stringField(body, 'sessionId');
|
|
477
|
+
if (sessionId === undefined)
|
|
478
|
+
return fail(res, 400, 'missing-session-id');
|
|
479
|
+
const record = state.readHandoff(sessionId, nodeId);
|
|
354
480
|
if (record === undefined)
|
|
355
481
|
return fail(res, 404, 'no-handoff');
|
|
356
482
|
await state.writeHandoff(withObserved(record, new Date().toISOString()));
|
|
357
|
-
sendJson(res, 200, { ok: true, handoff: handoffFor(state, nodeId, Date.now()) });
|
|
483
|
+
sendJson(res, 200, { ok: true, handoff: handoffFor(state, sessionId, nodeId, Date.now()) });
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* `GET /preferences` — the learner's interface language, if one is saved.
|
|
487
|
+
*
|
|
488
|
+
* The value lives on the learner singleton rather than in the browser so it
|
|
489
|
+
* survives DSH restarts (the web port changes, and browser storage does not
|
|
490
|
+
* follow it) and applies to every conversation. It is a display preference
|
|
491
|
+
* only: nothing about the diagnosis, teaching or stored evidence changes.
|
|
492
|
+
*/
|
|
493
|
+
function handlePreferences(state, res) {
|
|
494
|
+
const language = state.readLearner().preferredLanguage;
|
|
495
|
+
sendJson(res, 200, {
|
|
496
|
+
ok: true,
|
|
497
|
+
language: language === 'en' || language === 'zh' ? language : null,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/** One of the two languages this build ships copy for. */
|
|
501
|
+
const LANGUAGES = ['en', 'zh'];
|
|
502
|
+
/**
|
|
503
|
+
* `POST /preferences/language { language }` — remember the interface language.
|
|
504
|
+
*
|
|
505
|
+
* Validated here rather than trusted from the body: an unknown value would
|
|
506
|
+
* leave the UI without copy after the next load, so it is rejected instead of
|
|
507
|
+
* stored.
|
|
508
|
+
*/
|
|
509
|
+
async function handleSetLanguage(state, req, res) {
|
|
510
|
+
const body = await readJsonBody(req);
|
|
511
|
+
if (body === undefined)
|
|
512
|
+
return fail(res, 400, 'bad-body');
|
|
513
|
+
const language = body.language;
|
|
514
|
+
if (typeof language !== 'string' || !LANGUAGES.includes(language)) {
|
|
515
|
+
return fail(res, 400, 'unsupported-language');
|
|
516
|
+
}
|
|
517
|
+
const now = new Date().toISOString();
|
|
518
|
+
await state.updateLearner({ preferredLanguage: language }, now);
|
|
519
|
+
sendJson(res, 200, { ok: true, language });
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* `GET /project/session?courseId=&sessionId=` — which conversation resumes a project.
|
|
523
|
+
*
|
|
524
|
+
* A project is shared, but where it was last studied is not. The browser asks
|
|
525
|
+
* this before "entering learning" so continuing a project opens the
|
|
526
|
+
* conversation that actually holds its focus/run instead of rebinding the
|
|
527
|
+
* project to whichever conversation happens to be on screen. Read-only: no
|
|
528
|
+
* selection is written by answering it.
|
|
529
|
+
*/
|
|
530
|
+
function handleProjectSession(state, url, res) {
|
|
531
|
+
const courseId = url.searchParams.get('courseId');
|
|
532
|
+
if (courseId === null || courseId.length === 0)
|
|
533
|
+
return fail(res, 400, 'missing-course-id');
|
|
534
|
+
const resolved = resolveLearningSession(state, courseId, url.searchParams.get('sessionId') ?? undefined);
|
|
535
|
+
if (resolved === null)
|
|
536
|
+
return fail(res, 404, 'no-such-course');
|
|
537
|
+
sendJson(res, 200, { ok: true, sessionId: resolved.sessionId, reason: resolved.reason });
|
|
358
538
|
}
|
|
359
539
|
/**
|
|
360
540
|
* Build the request handler.
|
|
@@ -368,31 +548,137 @@ export function createApiHandler(deps) {
|
|
|
368
548
|
const method = req.method ?? 'GET';
|
|
369
549
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
370
550
|
const route = url.pathname.slice(API_PREFIX.length) || '/';
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
551
|
+
if (await handlePathApi(route, req, res, deps, url))
|
|
552
|
+
return;
|
|
553
|
+
// Read per request, not once: a conversation's project is its own, and the
|
|
554
|
+
// route that changes it must not be answered from a value captured before.
|
|
555
|
+
const asker = url.searchParams.get('sessionId') ?? undefined;
|
|
556
|
+
const course = projectFor(state, asker);
|
|
557
|
+
if (method === 'GET' && route === '/overview') {
|
|
558
|
+
return handleOverview(state, deps, url.searchParams.get('sessionId') ?? undefined, res);
|
|
559
|
+
}
|
|
560
|
+
if (method === 'GET' && route === '/project/session')
|
|
561
|
+
return handleProjectSession(state, url, res);
|
|
562
|
+
if (method === 'GET' && route === '/diagnostics') {
|
|
563
|
+
if (deps.journal === undefined)
|
|
564
|
+
return fail(res, 503, 'diagnostics-disabled');
|
|
565
|
+
try {
|
|
566
|
+
sendJson(res, 200, await buildDiagnostics(state, deps.journal, { ...(deps.pluginVersion === undefined ? {} : { pluginVersion: deps.pluginVersion }) }));
|
|
567
|
+
}
|
|
568
|
+
catch (cause) {
|
|
569
|
+
deps.onEvent?.('diagnostics.error', { error: cause.message.slice(0, 240) });
|
|
570
|
+
return fail(res, 500, 'diagnostics-unavailable');
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (method === 'POST' && route === '/project')
|
|
575
|
+
return await handleSelectProject(state, req, res);
|
|
374
576
|
if (method === 'GET' && route === '/export')
|
|
375
577
|
return handleExport(state, res);
|
|
376
578
|
if (method === 'POST' && route === '/reset')
|
|
377
579
|
return await handleReset(state, res);
|
|
580
|
+
if (method === 'POST' && route === '/plan/task') {
|
|
581
|
+
const input = await readJsonBody(req);
|
|
582
|
+
const taskId = input === undefined ? undefined : stringField(input, 'taskId');
|
|
583
|
+
if (taskId === undefined)
|
|
584
|
+
return fail(res, 400, 'missing-task-id');
|
|
585
|
+
const task = state.readTask(taskId);
|
|
586
|
+
if (task === undefined)
|
|
587
|
+
return fail(res, 404, 'no-such-task');
|
|
588
|
+
const status = input?.status === 'open' || input?.status === 'archived' ? input.status : 'done';
|
|
589
|
+
await state.writeTask({ ...task, status, updatedAt: new Date().toISOString() });
|
|
590
|
+
return sendJson(res, 200, { ok: true, tasks: sortTasks(state.listTasks()).map(taskView) });
|
|
591
|
+
}
|
|
592
|
+
if (method === 'GET' && route === '/insights') {
|
|
593
|
+
const courseId = url.searchParams.get('courseId') ?? course?.id;
|
|
594
|
+
if (courseId === undefined || courseId.length === 0)
|
|
595
|
+
return fail(res, 404, 'no-such-course');
|
|
596
|
+
const target = state.readCourse(courseId);
|
|
597
|
+
if (target === undefined)
|
|
598
|
+
return fail(res, 404, 'no-such-course');
|
|
599
|
+
return sendJson(res, 200, buildInsights(state, target, new Date().toISOString()));
|
|
600
|
+
}
|
|
601
|
+
if (method === 'POST' && route === '/review/undo') {
|
|
602
|
+
const input = await readJsonBody(req);
|
|
603
|
+
const cardId = input === undefined ? undefined : stringField(input, 'cardId');
|
|
604
|
+
if (cardId === undefined)
|
|
605
|
+
return fail(res, 400, 'missing-card-id');
|
|
606
|
+
const card = state.readReviewCard(cardId);
|
|
607
|
+
if (card === undefined)
|
|
608
|
+
return fail(res, 404, 'no-such-review-card');
|
|
609
|
+
const log = state.listReviewLog().filter((entry) => entry.cardId === cardId).sort((left, right) => left.at.localeCompare(right.at)).at(-1);
|
|
610
|
+
if (log === undefined || log.previousIntervalDays === undefined || log.previousEase === undefined || log.previousReps === undefined || log.previousLapses === undefined)
|
|
611
|
+
return fail(res, 409, 'cannot-undo');
|
|
612
|
+
const restored = {
|
|
613
|
+
...card, dueAt: log.previousDue, intervalDays: log.previousIntervalDays, ease: log.previousEase,
|
|
614
|
+
reps: log.previousReps, lapses: log.previousLapses, updatedAt: new Date().toISOString(),
|
|
615
|
+
};
|
|
616
|
+
await state.writeReviewCard(restored);
|
|
617
|
+
await state.deleteReviewLog(log.id);
|
|
618
|
+
return sendJson(res, 200, { ok: true, card: reviewView(restored), remaining: dueCards(state.listReviewCards()).length });
|
|
619
|
+
}
|
|
620
|
+
if (method === 'GET' && route === '/review') {
|
|
621
|
+
const cards = dueCards(state.listReviewCards()).slice(0, 20).map(reviewView);
|
|
622
|
+
return sendJson(res, 200, { ok: true, cards, count: cards.length });
|
|
623
|
+
}
|
|
624
|
+
if (method === 'POST' && route === '/review/grade') {
|
|
625
|
+
const input = await readJsonBody(req);
|
|
626
|
+
const cardId = input === undefined ? undefined : stringField(input, 'cardId');
|
|
627
|
+
const rating = input === undefined ? undefined : stringField(input, 'rating');
|
|
628
|
+
if (cardId === undefined)
|
|
629
|
+
return fail(res, 400, 'missing-card-id');
|
|
630
|
+
if (rating === undefined || !REVIEW_RATINGS.includes(rating))
|
|
631
|
+
return fail(res, 400, 'invalid-rating');
|
|
632
|
+
const card = state.readReviewCard(cardId);
|
|
633
|
+
if (card === undefined)
|
|
634
|
+
return fail(res, 404, 'no-such-review-card');
|
|
635
|
+
const graded = scheduleReview(card, rating);
|
|
636
|
+
await state.writeReviewCard(graded.card);
|
|
637
|
+
await state.writeReviewLog(graded.log);
|
|
638
|
+
return sendJson(res, 200, { ok: true, card: reviewView(graded.card), remaining: dueCards(state.listReviewCards()).length });
|
|
639
|
+
}
|
|
640
|
+
if (method === 'GET' && route === '/plan/ics') {
|
|
641
|
+
const calendar = buildIcs(state.listTasks(), new Date());
|
|
642
|
+
res.statusCode = 200;
|
|
643
|
+
res.setHeader('content-type', 'text/calendar; charset=utf-8');
|
|
644
|
+
res.setHeader('content-disposition', 'attachment; filename="dsh-diagnostic-tutor-plan.ics"');
|
|
645
|
+
res.setHeader('cache-control', 'no-store');
|
|
646
|
+
res.end(calendar);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (method === 'GET' && route === '/source') {
|
|
650
|
+
const sourceId = url.searchParams.get('id');
|
|
651
|
+
if (sourceId === null || sourceId.length === 0)
|
|
652
|
+
return fail(res, 400, 'missing-id');
|
|
653
|
+
const source = state.readSource(sourceId);
|
|
654
|
+
if (source === undefined)
|
|
655
|
+
return fail(res, 404, 'no-such-source');
|
|
656
|
+
return sendJson(res, 200, { ok: true, source });
|
|
657
|
+
}
|
|
378
658
|
if (method === 'GET' && route === '/node') {
|
|
379
659
|
const nodeId = url.searchParams.get('id');
|
|
380
660
|
if (nodeId === null || nodeId.length === 0)
|
|
381
661
|
return fail(res, 400, 'missing-id');
|
|
382
|
-
return handleNode(state,
|
|
662
|
+
return handleNode(state, nodeId, res);
|
|
383
663
|
}
|
|
384
664
|
if (method === 'GET' && route === '/lesson') {
|
|
385
665
|
const nodeId = url.searchParams.get('nodeId');
|
|
386
666
|
if (nodeId === null || nodeId.length === 0)
|
|
387
667
|
return fail(res, 400, 'missing-node-id');
|
|
388
|
-
return handleLesson(state,
|
|
668
|
+
return handleLesson(state, nodeId, res);
|
|
389
669
|
}
|
|
390
670
|
if (method === 'POST' && route === '/focus') {
|
|
391
|
-
return handleStartFocus(state, deps,
|
|
671
|
+
return handleStartFocus(state, deps, req, res);
|
|
392
672
|
}
|
|
393
673
|
if (method === 'POST' && route === '/handoff/observed') {
|
|
394
674
|
return handleObserved(state, req, res);
|
|
395
675
|
}
|
|
676
|
+
if (method === 'GET' && route === '/preferences') {
|
|
677
|
+
return handlePreferences(state, res);
|
|
678
|
+
}
|
|
679
|
+
if (method === 'POST' && route === '/preferences/language') {
|
|
680
|
+
return await handleSetLanguage(state, req, res);
|
|
681
|
+
}
|
|
396
682
|
return fail(res, method === 'GET' || method === 'POST' ? 404 : 405, 'no-such-route');
|
|
397
683
|
};
|
|
398
684
|
}
|