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/tools.js
CHANGED
|
@@ -18,9 +18,37 @@
|
|
|
18
18
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
19
19
|
import { MAX_NODES_PER_UPDATE, acceptNewNodes, recordEvidence, setNodeState } from './diagnosis.js';
|
|
20
20
|
import { withLesson } from './handoff.js';
|
|
21
|
-
import { BLOCK_SCHEMAS,
|
|
22
|
-
import {
|
|
21
|
+
import { BLOCK_SCHEMAS, KnownBlockSchema, LESSON_ORIGINS, MAX_BLOCKS_PER_LESSON, MAX_BLOCKS_PER_UPDATE, MAX_LESSON_BYTES, LessonSchema, } from './lesson.js';
|
|
22
|
+
import { renderMathFragments, renderTexToMathML } from './math.js';
|
|
23
|
+
import { blocksMarkdown } from './lesson-markdown.js';
|
|
24
|
+
import { AnswerSubmissionSchema, recordBlockResponse } from './interactions.js';
|
|
25
|
+
import { newNode, PROJECT_BINDINGS, PROJECT_SELECTIONS } from './state.js';
|
|
23
26
|
import { EVIDENCE_KINDS, NODE_RELATIONS, NODE_STATES, READINESS_OUTCOMES, targetRequirement, } from './vocabulary.js';
|
|
27
|
+
import { planningTools } from './path-tools.js';
|
|
28
|
+
import { sourceTools } from './source-tools.js';
|
|
29
|
+
import { planTools } from './plan-tools.js';
|
|
30
|
+
import { reviewTools } from './review-tools.js';
|
|
31
|
+
import { syncLessonFlashcards } from './review-sync.js';
|
|
32
|
+
import { activeAttempt } from './path.js';
|
|
33
|
+
import { requireAttempt, writeCourseTeaching } from './path-runtime.js';
|
|
34
|
+
/**
|
|
35
|
+
* The conversation a tool call belongs to.
|
|
36
|
+
*
|
|
37
|
+
* `udt_decide_next` is called by the model, which has no idea which
|
|
38
|
+
* conversation it is running in — but the harness does: every execution carries
|
|
39
|
+
* the agent on whose behalf it runs, and an agent is session-backed, so
|
|
40
|
+
* `agent.id` IS the session id. Without this the decision would be stored
|
|
41
|
+
* course-wide and one conversation's decision would overwrite another's, which
|
|
42
|
+
* is the bug this keying exists to fix.
|
|
43
|
+
*
|
|
44
|
+
* @param exec - the execution context the registry hands the tool.
|
|
45
|
+
* @returns the session id, or undefined when the call has no agent.
|
|
46
|
+
*/
|
|
47
|
+
function sessionOf(exec) {
|
|
48
|
+
const agent = exec?.agent;
|
|
49
|
+
const id = agent?.id;
|
|
50
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
51
|
+
}
|
|
24
52
|
/* -------------------------------------------------------------------------- */
|
|
25
53
|
/* Helpers */
|
|
26
54
|
/* -------------------------------------------------------------------------- */
|
|
@@ -63,16 +91,29 @@ function nowIso() {
|
|
|
63
91
|
function violationMessage(violations) {
|
|
64
92
|
return violations.map((entry) => `${entry.code}: ${entry.message}`).join(' | ');
|
|
65
93
|
}
|
|
66
|
-
/** Resolve
|
|
67
|
-
function resolveCourseId(state, requested) {
|
|
68
|
-
const courseId = requested ??
|
|
94
|
+
/** Resolve an explicit shared course or this conversation's selected project. */
|
|
95
|
+
function resolveCourseId(state, requested, project) {
|
|
96
|
+
const courseId = requested ?? project.course?.id;
|
|
69
97
|
if (!courseId) {
|
|
70
|
-
throw new Error('no course given and
|
|
98
|
+
throw new Error('no course given and this conversation has no selected project; call udt_goal_create for a new goal or pass courseId explicitly');
|
|
71
99
|
}
|
|
72
100
|
if (!state.readCourse(courseId))
|
|
73
101
|
throw new Error(`no course "${courseId}"`);
|
|
74
102
|
return courseId;
|
|
75
103
|
}
|
|
104
|
+
/** A tool write cannot silently work outside the conversation's selected project. */
|
|
105
|
+
function requireProjectMatch(project, courseId) {
|
|
106
|
+
if (project.course !== undefined && project.course.id !== courseId) {
|
|
107
|
+
throw new Error(`this conversation selected course "${project.course.id}"; cannot write course "${courseId}" without selecting it first`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const PROJECT_BINDING_PROPERTIES = {
|
|
111
|
+
projectAdopted: { type: 'boolean', required: true, description: 'Whether this write newly selected the project for this conversation.' },
|
|
112
|
+
projectBinding: {
|
|
113
|
+
type: 'string', required: true, enum: [...PROJECT_BINDINGS],
|
|
114
|
+
description: 'adopted | already-selected | not-selected | missing-session-id. Shared data can be stored without a conversation binding.',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
76
117
|
/**
|
|
77
118
|
* Project the state snapshot onto the tool's canonical output.
|
|
78
119
|
*
|
|
@@ -83,8 +124,9 @@ function resolveCourseId(state, requested) {
|
|
|
83
124
|
* location and version are internal diagnostics and must not become text a
|
|
84
125
|
* model can echo back to a learner.
|
|
85
126
|
*/
|
|
86
|
-
function statusValue(state) {
|
|
127
|
+
function statusValue(state, sessionId) {
|
|
87
128
|
const snapshot = state.snapshot();
|
|
129
|
+
const project = state.resolveProject(sessionId);
|
|
88
130
|
const value = {
|
|
89
131
|
domain: snapshot.domain,
|
|
90
132
|
version: snapshot.version,
|
|
@@ -92,18 +134,30 @@ function statusValue(state) {
|
|
|
92
134
|
courseCount: snapshot.courseCount,
|
|
93
135
|
nodeCount: snapshot.nodeCount,
|
|
94
136
|
courses: snapshot.courses.map((course) => ({ ...course })),
|
|
137
|
+
selection: project.selection,
|
|
138
|
+
sessionAvailable: project.sessionAvailable,
|
|
95
139
|
};
|
|
96
|
-
|
|
140
|
+
if (project.course) {
|
|
141
|
+
const path = state.readPath(project.course.id);
|
|
142
|
+
const run = sessionId ? state.readCourseRun(project.course.id, sessionId) : undefined;
|
|
143
|
+
if (path || run)
|
|
144
|
+
value.learningPathContext = JSON.stringify({
|
|
145
|
+
tool: 'udt_path_get', courseId: project.course.id, revision: path?.revision ?? 0,
|
|
146
|
+
briefRevision: path?.briefRevision ?? 0, acceptedVersion: path?.acceptedVersion ?? null,
|
|
147
|
+
activeAttempt: (() => { const entry = activeAttempt(run); return entry ? { id: entry.id, itemId: entry.itemId, version: entry.version, nodeId: entry.nodeId, status: entry.status } : null; })(),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const { preferredLanguage, mode } = snapshot.learner;
|
|
97
151
|
if (preferredLanguage !== undefined)
|
|
98
152
|
value.preferredLanguage = preferredLanguage;
|
|
99
153
|
if (mode !== undefined)
|
|
100
154
|
value.mode = mode;
|
|
101
|
-
if (
|
|
102
|
-
value.
|
|
155
|
+
if (project.course !== undefined)
|
|
156
|
+
value.courseId = project.course.id;
|
|
103
157
|
// The focus is how the tutor learns which node the learner asked to work on.
|
|
104
158
|
// It is reported here rather than in a tool of its own: it is one fact about
|
|
105
159
|
// the runtime, not a separate concern.
|
|
106
|
-
const focused = state.activeFocus();
|
|
160
|
+
const focused = sessionId === undefined ? undefined : state.activeFocus(sessionId);
|
|
107
161
|
if (focused !== undefined) {
|
|
108
162
|
value.focus = {
|
|
109
163
|
courseId: focused.course.id,
|
|
@@ -115,8 +169,17 @@ function statusValue(state) {
|
|
|
115
169
|
}
|
|
116
170
|
// A recommendation the learner has not acted on yet. Reported so the tutor
|
|
117
171
|
// can see its own last decision instead of re-deciding from scratch.
|
|
118
|
-
|
|
119
|
-
|
|
172
|
+
//
|
|
173
|
+
// Read from the focus RECORD, not from the active focus: a decision that
|
|
174
|
+
// names a target ends the focus, so keying this off `activeFocus` would hide
|
|
175
|
+
// the recommendation in exactly the case it matters most — the tutor having
|
|
176
|
+
// just said where to go next.
|
|
177
|
+
const focusRecord = sessionId === undefined || project.course === undefined
|
|
178
|
+
? undefined
|
|
179
|
+
: state.readFocus(sessionId, project.course.id);
|
|
180
|
+
const pending = sessionId === undefined || focusRecord?.nextStepId === undefined
|
|
181
|
+
? undefined
|
|
182
|
+
: state.readNextStep(sessionId, focusRecord.nextStepId);
|
|
120
183
|
if (pending !== undefined) {
|
|
121
184
|
const from = state.readNode(pending.fromNodeId);
|
|
122
185
|
const target = pending.targetNodeId === undefined ? undefined : state.readNode(pending.targetNodeId);
|
|
@@ -141,6 +204,11 @@ function udtStatusTool(state) {
|
|
|
141
204
|
'initialized, the learner profile, the registered learning goals and how many diagnosis-map nodes exist. ' +
|
|
142
205
|
'Call this before starting a session to see whether there is existing state to continue from, and after ' +
|
|
143
206
|
'changing state to confirm what was stored. ' +
|
|
207
|
+
'`courses`, counts and preferences are learner-shared; `selection`, `courseId`, focus and next step belong only to this conversation. ' +
|
|
208
|
+
'`none` means no project selected; `new` means the learner asked to start a new project: use udt_goal_create for that goal, ' +
|
|
209
|
+
'and do not reuse a historical project as the current one. `project` names the selected course. ' +
|
|
210
|
+
'If sessionAvailable is false, conversation binding is unavailable. ' +
|
|
211
|
+
'If learningPathContext exists, read udt_path_get to continue goal setup, assessment, route or active planned teaching. ' +
|
|
144
212
|
'Read-only: it records no teaching decision.',
|
|
145
213
|
parameters: {},
|
|
146
214
|
output: {
|
|
@@ -171,12 +239,15 @@ function udtStatusTool(state) {
|
|
|
171
239
|
},
|
|
172
240
|
},
|
|
173
241
|
},
|
|
242
|
+
learningPathContext: { type: 'string', description: 'Stored curriculum/attempt context as JSON. Read udt_path_get before planned teaching, even when no node focus exists.' },
|
|
174
243
|
preferredLanguage: { type: 'string', description: 'Learner preference, when recorded.' },
|
|
175
244
|
mode: {
|
|
176
245
|
type: 'string',
|
|
177
246
|
description: 'Teaching mode preference: auto | zero-base | standard | advanced.',
|
|
178
247
|
},
|
|
179
|
-
|
|
248
|
+
selection: { type: 'string', required: true, enum: [...PROJECT_SELECTIONS], description: 'This conversation\'s project selection: none | new | project.' },
|
|
249
|
+
sessionAvailable: { type: 'boolean', required: true, description: 'Whether this tool call has a reliable conversation id.' },
|
|
250
|
+
courseId: { type: 'string', description: 'Only present for selection=project: this conversation\'s selected course.' },
|
|
180
251
|
focus: {
|
|
181
252
|
type: 'object',
|
|
182
253
|
additionalProperties: false,
|
|
@@ -214,11 +285,13 @@ function udtStatusTool(state) {
|
|
|
214
285
|
render: (_args, value) => [
|
|
215
286
|
{
|
|
216
287
|
type: 'text',
|
|
217
|
-
|
|
288
|
+
// Native tool messages carry render content, not the execute value.
|
|
289
|
+
// The tutor must see the conversation selection and usable ids.
|
|
290
|
+
text: JSON.stringify(value),
|
|
218
291
|
},
|
|
219
292
|
],
|
|
220
293
|
},
|
|
221
|
-
execute: () => Promise.resolve(statusValue(state)),
|
|
294
|
+
execute: (_args, exec) => Promise.resolve(statusValue(state, sessionOf(exec))),
|
|
222
295
|
presentCall: () => ({
|
|
223
296
|
card: 'generic',
|
|
224
297
|
title: 'Read learning-runtime status',
|
|
@@ -241,6 +314,10 @@ function udtStatusTool(state) {
|
|
|
241
314
|
* active. Pausing is reversible and never touches the other goal's map.
|
|
242
315
|
*/
|
|
243
316
|
function udtGoalCreateTool(state) {
|
|
317
|
+
// ID allocation must see the preceding creation's course, and activation
|
|
318
|
+
// must finish before another creation pauses it. A failed write releases
|
|
319
|
+
// the queue so later calls can still create goals.
|
|
320
|
+
let goalWrites = Promise.resolve();
|
|
244
321
|
return defineTool({
|
|
245
322
|
name: 'udt_goal_create',
|
|
246
323
|
description: 'Record a learning goal and start its diagnosis map. ' +
|
|
@@ -248,7 +325,10 @@ function udtGoalCreateTool(state) {
|
|
|
248
325
|
'This creates ONLY the goal and the single root node of the map — it never generates a course outline, ' +
|
|
249
326
|
'syllabus or list of topics. Further nodes are added later, one at a time, as diagnosis reveals ' +
|
|
250
327
|
'prerequisites or blockers (see udt_map_update). ' +
|
|
251
|
-
'Creating a goal
|
|
328
|
+
'Creating a goal marks it active in the shared library and pauses any previously active goal. ' +
|
|
329
|
+
'It selects this conversation\'s project only for none/new selection; an existing project choice is preserved. ' +
|
|
330
|
+
'Read projectBinding in the result to confirm whether the conversation is bound. ' +
|
|
331
|
+
'Then read udt_path_get. Interactively clarify purpose, observable target outcomes, time horizon and budget before initial diagnosis and a proposed course route. Planning has its own tools; never put an undiagnosed syllabus into the diagnosis map.',
|
|
252
332
|
parameters: {
|
|
253
333
|
title: {
|
|
254
334
|
type: 'string',
|
|
@@ -278,54 +358,65 @@ function udtGoalCreateTool(state) {
|
|
|
278
358
|
description: 'Goals that were active and are now paused.',
|
|
279
359
|
items: { type: 'string' },
|
|
280
360
|
},
|
|
361
|
+
...PROJECT_BINDING_PROPERTIES,
|
|
281
362
|
},
|
|
282
363
|
},
|
|
283
364
|
render: (_args, value) => [
|
|
284
365
|
{
|
|
285
366
|
type: 'text',
|
|
286
|
-
text:
|
|
367
|
+
text: JSON.stringify(value),
|
|
287
368
|
},
|
|
288
369
|
],
|
|
289
370
|
},
|
|
290
|
-
execute: async (args) => {
|
|
371
|
+
execute: async (args, exec) => {
|
|
291
372
|
const { title, goal } = args;
|
|
292
373
|
const now = nowIso();
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
.map((course) => course.id);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
374
|
+
const sessionId = sessionOf(exec);
|
|
375
|
+
const project = state.resolveProject(sessionId);
|
|
376
|
+
// Capture the conversation choice before waiting: a choice made while
|
|
377
|
+
// this call is queued must invalidate its eventual adoption too.
|
|
378
|
+
const result = goalWrites.then(async () => {
|
|
379
|
+
const taken = new Set(state.listCourses().map((course) => course.id));
|
|
380
|
+
const courseId = allocateId(slugify(title), taken);
|
|
381
|
+
const previouslyActive = state
|
|
382
|
+
.listCourses()
|
|
383
|
+
.filter((course) => course.status === 'active' && course.id !== courseId)
|
|
384
|
+
.map((course) => course.id);
|
|
385
|
+
await state.writeCourse({
|
|
386
|
+
id: courseId,
|
|
387
|
+
title,
|
|
388
|
+
goal,
|
|
389
|
+
status: 'active',
|
|
390
|
+
createdAt: now,
|
|
391
|
+
updatedAt: now,
|
|
392
|
+
});
|
|
393
|
+
// Focus it (this pauses the others) and point the learner at it.
|
|
394
|
+
await state.activateCourse(courseId, now);
|
|
395
|
+
const goalNode = newNode({
|
|
396
|
+
id: `${courseId}:goal`,
|
|
397
|
+
courseId,
|
|
398
|
+
title,
|
|
399
|
+
relation: 'goal',
|
|
400
|
+
// The goal node carries the learner's statement as evidence, yet stays
|
|
401
|
+
// `unconfirmed`: a goal is the frame of the map, not a mastery claim.
|
|
402
|
+
evidence: [{ kind: 'goal-stated', at: now, note: goal }],
|
|
403
|
+
now,
|
|
404
|
+
});
|
|
405
|
+
await state.writeNode(goalNode);
|
|
406
|
+
const binding = await state.adoptProject(sessionId, courseId, now, project, true);
|
|
407
|
+
return {
|
|
408
|
+
courseId,
|
|
409
|
+
title,
|
|
410
|
+
goal,
|
|
411
|
+
status: 'active',
|
|
412
|
+
createdAt: now,
|
|
413
|
+
goalNodeId: goalNode.id,
|
|
414
|
+
pausedCourseIds: previouslyActive,
|
|
415
|
+
...binding,
|
|
416
|
+
};
|
|
318
417
|
});
|
|
319
|
-
|
|
320
|
-
return
|
|
321
|
-
courseId,
|
|
322
|
-
title,
|
|
323
|
-
goal,
|
|
324
|
-
status: 'active',
|
|
325
|
-
createdAt: now,
|
|
326
|
-
goalNodeId: goalNode.id,
|
|
327
|
-
pausedCourseIds: previouslyActive,
|
|
328
|
-
};
|
|
418
|
+
goalWrites = result.catch(() => undefined);
|
|
419
|
+
return result;
|
|
329
420
|
},
|
|
330
421
|
presentCall: (args) => ({
|
|
331
422
|
card: 'generic',
|
|
@@ -345,9 +436,9 @@ function udtMapGetTool(state) {
|
|
|
345
436
|
'recorded against it. Use this to decide the next diagnostic step. ' +
|
|
346
437
|
'A node state is one of: unconfirmed (no evidence yet), explained, practiced, checked, weak, blocked, ' +
|
|
347
438
|
'confirmed. Nothing here is a progress percentage or a score. ' +
|
|
348
|
-
'Omit courseId to read
|
|
439
|
+
'Omit courseId to read this conversation\'s selected project. An explicit courseId reads shared data without selecting it.',
|
|
349
440
|
parameters: {
|
|
350
|
-
courseId: { type: 'string', description: 'Goal to read; defaults to
|
|
441
|
+
courseId: { type: 'string', description: 'Goal to read; defaults only to this conversation\'s selected project.' },
|
|
351
442
|
},
|
|
352
443
|
output: {
|
|
353
444
|
schema: {
|
|
@@ -400,14 +491,12 @@ function udtMapGetTool(state) {
|
|
|
400
491
|
render: (_args, value) => [
|
|
401
492
|
{
|
|
402
493
|
type: 'text',
|
|
403
|
-
text:
|
|
404
|
-
.map((node) => `${node.title} [${node.state}]`)
|
|
405
|
-
.join(', ')}`,
|
|
494
|
+
text: JSON.stringify(value),
|
|
406
495
|
},
|
|
407
496
|
],
|
|
408
497
|
},
|
|
409
|
-
execute: (args) => {
|
|
410
|
-
const courseId = resolveCourseId(state, args.courseId);
|
|
498
|
+
execute: (args, exec) => {
|
|
499
|
+
const courseId = resolveCourseId(state, args.courseId, state.resolveProject(sessionOf(exec)));
|
|
411
500
|
const map = state.readMap(courseId);
|
|
412
501
|
if (!map)
|
|
413
502
|
throw new Error(`no course "${courseId}"`);
|
|
@@ -451,11 +540,14 @@ function udtMapUpdateTool(state) {
|
|
|
451
540
|
'(part-of | prerequisite | related | goal). Nodes are born "unconfirmed" unless you pass `state`.\n' +
|
|
452
541
|
'• "set-state" — move one node to a new state. "confirmed" is REFUSED unless the node already carries a ' +
|
|
453
542
|
'check or transfer evidence entry; explanation or practice alone never confirms.\n' +
|
|
454
|
-
'• "add-evidence" — append one observation to a node. Record what happened, not how good it was
|
|
543
|
+
'• "add-evidence" — append one observation to a node. Record what happened, not how good it was. ' +
|
|
544
|
+
'During an active planned lesson, supply its explicit attemptId and bound nodeId; the runtime stamps the conversation/attempt provenance. Stale attempts are refused.\n' +
|
|
455
545
|
'A single call may add at most 8 nodes and a map holds at most 40. Never use this to plant a whole ' +
|
|
456
|
-
'syllabus: add only what the current diagnosis justifies.'
|
|
546
|
+
'syllabus: add only what the current diagnosis justifies. ' +
|
|
547
|
+
'An explicit courseId/nodeId write may select the project only when this conversation has no selection. ' +
|
|
548
|
+
'Historical writes never replace new/project selection; check projectBinding in the result.',
|
|
457
549
|
parameters: {
|
|
458
|
-
courseId: { type: 'string', description: 'Goal to update; defaults to the
|
|
550
|
+
courseId: { type: 'string', description: 'Goal to update; defaults to this conversation\'s selected project, or the explicitly named node\'s course.' },
|
|
459
551
|
op: {
|
|
460
552
|
type: 'string',
|
|
461
553
|
required: true,
|
|
@@ -476,7 +568,8 @@ function udtMapUpdateTool(state) {
|
|
|
476
568
|
},
|
|
477
569
|
},
|
|
478
570
|
},
|
|
479
|
-
nodeId: { type: 'string', description: 'For op=set-state / add-evidence: the target node.' },
|
|
571
|
+
nodeId: { type: 'string', description: 'For op=set-state / add-evidence: the target node. Planned evidence must use the active attempt\'s bound node.' },
|
|
572
|
+
attemptId: { type: 'string', description: 'For op=add-evidence during planned learning: required active attemptId from udt_path_get. Provenance is stamped by the runtime.' },
|
|
480
573
|
state: { type: 'string', enum: [...NODE_STATES], description: 'For op=set-state: the new state.' },
|
|
481
574
|
evidenceKind: {
|
|
482
575
|
type: 'string',
|
|
@@ -508,20 +601,24 @@ function udtMapUpdateTool(state) {
|
|
|
508
601
|
state: { type: 'string', description: 'The node\'s state after this call.' },
|
|
509
602
|
evidenceCount: { type: 'integer', description: 'Evidence entries on the node after this call.' },
|
|
510
603
|
nodeCount: { type: 'integer', required: true, description: 'Total nodes in the course after this call.' },
|
|
604
|
+
...PROJECT_BINDING_PROPERTIES,
|
|
511
605
|
},
|
|
512
606
|
},
|
|
513
607
|
render: (_args, value) => [
|
|
514
608
|
{
|
|
515
609
|
type: 'text',
|
|
516
|
-
text: value
|
|
517
|
-
? `Added ${value.addedNodeIds.length} node(s); the map now has ${value.nodeCount}.`
|
|
518
|
-
: `Node ${value.nodeId ?? ''} is now "${value.state ?? ''}" with ${value.evidenceCount ?? 0} evidence entr(ies).`,
|
|
610
|
+
text: JSON.stringify(value),
|
|
519
611
|
},
|
|
520
612
|
],
|
|
521
613
|
},
|
|
522
|
-
execute: async (args) => {
|
|
614
|
+
execute: async (args, exec) => {
|
|
523
615
|
const input = args;
|
|
524
|
-
const
|
|
616
|
+
const sessionId = sessionOf(exec);
|
|
617
|
+
const project = state.resolveProject(sessionId);
|
|
618
|
+
const nodeCourseId = input.op === 'add-nodes' || input.nodeId === undefined
|
|
619
|
+
? undefined : state.readNode(input.nodeId)?.courseId;
|
|
620
|
+
const courseId = resolveCourseId(state, input.courseId ?? nodeCourseId, project);
|
|
621
|
+
requireProjectMatch(project, courseId);
|
|
525
622
|
const now = input.at ?? nowIso();
|
|
526
623
|
const existing = state.listNodes(courseId);
|
|
527
624
|
if (input.op === 'add-nodes') {
|
|
@@ -562,6 +659,7 @@ function udtMapUpdateTool(state) {
|
|
|
562
659
|
op: input.op,
|
|
563
660
|
addedNodeIds: structured.map((node) => node.id),
|
|
564
661
|
nodeCount: existing.length + structured.length,
|
|
662
|
+
...await state.adoptProject(sessionId, courseId, now, project),
|
|
565
663
|
};
|
|
566
664
|
}
|
|
567
665
|
// Both remaining ops target exactly one node.
|
|
@@ -591,6 +689,7 @@ function udtMapUpdateTool(state) {
|
|
|
591
689
|
state: stored.state,
|
|
592
690
|
evidenceCount: stored.evidence.length,
|
|
593
691
|
nodeCount: existing.length,
|
|
692
|
+
...await state.adoptProject(sessionId, courseId, now, project),
|
|
594
693
|
};
|
|
595
694
|
}
|
|
596
695
|
// op === 'add-evidence'
|
|
@@ -602,13 +701,23 @@ function udtMapUpdateTool(state) {
|
|
|
602
701
|
...(input.evidenceNote === undefined ? {} : { note: input.evidenceNote }),
|
|
603
702
|
...(input.readiness === undefined ? {} : { readiness: input.readiness }),
|
|
604
703
|
};
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
704
|
+
const stored = await state.mutateNode(courseId, nodeId, (current) => {
|
|
705
|
+
requireProjectMatch(state.resolveProject(sessionId), courseId);
|
|
706
|
+
const planned = sessionId ? activeAttempt(state.readCourseRun(courseId, sessionId)) : undefined;
|
|
707
|
+
if (planned && ['requested', 'working'].includes(planned.status) && !input.attemptId)
|
|
708
|
+
throw new Error('planned-evidence-needs-attemptId: read udt_path_get and bind its diagnosed node');
|
|
709
|
+
const attempt = input.attemptId ? requireAttempt(state, courseId, sessionId, input.attemptId) : undefined;
|
|
710
|
+
if (attempt && attempt.nodeId !== current.id)
|
|
711
|
+
throw new Error('bind-the-node-before-recording-planned-evidence');
|
|
712
|
+
const trusted = attempt ? { ...evidence, sessionId: sessionId, attemptId: attempt.id } : evidence;
|
|
713
|
+
const result = recordEvidence(current, trusted, {
|
|
714
|
+
now,
|
|
715
|
+
...(input.state === undefined ? {} : { state: input.state }),
|
|
716
|
+
});
|
|
717
|
+
if (!result.ok)
|
|
718
|
+
throw new Error(violationMessage(result.violations));
|
|
719
|
+
return result.node;
|
|
608
720
|
});
|
|
609
|
-
if (!result.ok)
|
|
610
|
-
throw new Error(violationMessage(result.violations));
|
|
611
|
-
const stored = await state.writeNode(result.node);
|
|
612
721
|
return {
|
|
613
722
|
courseId,
|
|
614
723
|
op: input.op,
|
|
@@ -617,6 +726,7 @@ function udtMapUpdateTool(state) {
|
|
|
617
726
|
state: stored.state,
|
|
618
727
|
evidenceCount: stored.evidence.length,
|
|
619
728
|
nodeCount: existing.length,
|
|
729
|
+
...await state.adoptProject(sessionId, courseId, now, project),
|
|
620
730
|
};
|
|
621
731
|
},
|
|
622
732
|
presentCall: (args) => ({
|
|
@@ -645,6 +755,17 @@ const BLOCK_PARAM_SPEC = {
|
|
|
645
755
|
additionalProperties: false,
|
|
646
756
|
properties: {
|
|
647
757
|
id: { type: 'string', required: true },
|
|
758
|
+
sourceRefs: {
|
|
759
|
+
type: 'array',
|
|
760
|
+
items: {
|
|
761
|
+
type: 'object',
|
|
762
|
+
additionalProperties: false,
|
|
763
|
+
properties: {
|
|
764
|
+
sourceId: { type: 'string', required: true },
|
|
765
|
+
locator: { type: 'string', required: true },
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
},
|
|
648
769
|
type: { type: 'string', required: true, enum: ['text'] },
|
|
649
770
|
content: {
|
|
650
771
|
type: 'object',
|
|
@@ -658,6 +779,17 @@ const BLOCK_PARAM_SPEC = {
|
|
|
658
779
|
additionalProperties: false,
|
|
659
780
|
properties: {
|
|
660
781
|
id: { type: 'string', required: true },
|
|
782
|
+
sourceRefs: {
|
|
783
|
+
type: 'array',
|
|
784
|
+
items: {
|
|
785
|
+
type: 'object',
|
|
786
|
+
additionalProperties: false,
|
|
787
|
+
properties: {
|
|
788
|
+
sourceId: { type: 'string', required: true },
|
|
789
|
+
locator: { type: 'string', required: true },
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
},
|
|
661
793
|
type: { type: 'string', required: true, enum: ['example'] },
|
|
662
794
|
content: {
|
|
663
795
|
type: 'object',
|
|
@@ -675,6 +807,17 @@ const BLOCK_PARAM_SPEC = {
|
|
|
675
807
|
additionalProperties: false,
|
|
676
808
|
properties: {
|
|
677
809
|
id: { type: 'string', required: true },
|
|
810
|
+
sourceRefs: {
|
|
811
|
+
type: 'array',
|
|
812
|
+
items: {
|
|
813
|
+
type: 'object',
|
|
814
|
+
additionalProperties: false,
|
|
815
|
+
properties: {
|
|
816
|
+
sourceId: { type: 'string', required: true },
|
|
817
|
+
locator: { type: 'string', required: true },
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
},
|
|
678
821
|
type: { type: 'string', required: true, enum: ['diagram'] },
|
|
679
822
|
content: {
|
|
680
823
|
type: 'object',
|
|
@@ -692,6 +835,17 @@ const BLOCK_PARAM_SPEC = {
|
|
|
692
835
|
additionalProperties: false,
|
|
693
836
|
properties: {
|
|
694
837
|
id: { type: 'string', required: true },
|
|
838
|
+
sourceRefs: {
|
|
839
|
+
type: 'array',
|
|
840
|
+
items: {
|
|
841
|
+
type: 'object',
|
|
842
|
+
additionalProperties: false,
|
|
843
|
+
properties: {
|
|
844
|
+
sourceId: { type: 'string', required: true },
|
|
845
|
+
locator: { type: 'string', required: true },
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
},
|
|
695
849
|
type: { type: 'string', required: true, enum: ['check'] },
|
|
696
850
|
content: {
|
|
697
851
|
type: 'object',
|
|
@@ -704,6 +858,200 @@ const BLOCK_PARAM_SPEC = {
|
|
|
704
858
|
},
|
|
705
859
|
},
|
|
706
860
|
},
|
|
861
|
+
{
|
|
862
|
+
type: 'object',
|
|
863
|
+
additionalProperties: false,
|
|
864
|
+
properties: {
|
|
865
|
+
id: { type: 'string', required: true },
|
|
866
|
+
sourceRefs: {
|
|
867
|
+
type: 'array',
|
|
868
|
+
items: {
|
|
869
|
+
type: 'object',
|
|
870
|
+
additionalProperties: false,
|
|
871
|
+
properties: {
|
|
872
|
+
sourceId: { type: 'string', required: true },
|
|
873
|
+
locator: { type: 'string', required: true },
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
type: { type: 'string', required: true, enum: ['formula'] },
|
|
878
|
+
content: {
|
|
879
|
+
type: 'object',
|
|
880
|
+
additionalProperties: false,
|
|
881
|
+
properties: {
|
|
882
|
+
tex: { type: 'string', required: true, description: 'TeX source without delimiters.' },
|
|
883
|
+
display: { type: 'boolean' },
|
|
884
|
+
caption: { type: 'string' },
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
type: 'object',
|
|
891
|
+
additionalProperties: false,
|
|
892
|
+
properties: {
|
|
893
|
+
id: { type: 'string', required: true },
|
|
894
|
+
sourceRefs: {
|
|
895
|
+
type: 'array',
|
|
896
|
+
items: {
|
|
897
|
+
type: 'object',
|
|
898
|
+
additionalProperties: false,
|
|
899
|
+
properties: {
|
|
900
|
+
sourceId: { type: 'string', required: true },
|
|
901
|
+
locator: { type: 'string', required: true },
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
type: { type: 'string', required: true, enum: ['code'] },
|
|
906
|
+
content: {
|
|
907
|
+
type: 'object',
|
|
908
|
+
additionalProperties: false,
|
|
909
|
+
properties: {
|
|
910
|
+
language: { type: 'string' },
|
|
911
|
+
source: { type: 'string', required: true },
|
|
912
|
+
caption: { type: 'string' },
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
type: 'object',
|
|
919
|
+
additionalProperties: false,
|
|
920
|
+
properties: {
|
|
921
|
+
id: { type: 'string', required: true },
|
|
922
|
+
sourceRefs: {
|
|
923
|
+
type: 'array',
|
|
924
|
+
items: {
|
|
925
|
+
type: 'object',
|
|
926
|
+
additionalProperties: false,
|
|
927
|
+
properties: {
|
|
928
|
+
sourceId: { type: 'string', required: true },
|
|
929
|
+
locator: { type: 'string', required: true },
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
type: { type: 'string', required: true, enum: ['quiz'] },
|
|
934
|
+
content: {
|
|
935
|
+
type: 'object',
|
|
936
|
+
additionalProperties: false,
|
|
937
|
+
properties: {
|
|
938
|
+
title: { type: 'string' },
|
|
939
|
+
questions: {
|
|
940
|
+
type: 'array', required: true,
|
|
941
|
+
items: {
|
|
942
|
+
type: 'object', additionalProperties: false,
|
|
943
|
+
properties: {
|
|
944
|
+
id: { type: 'string', required: true },
|
|
945
|
+
sourceRefs: {
|
|
946
|
+
type: 'array',
|
|
947
|
+
items: {
|
|
948
|
+
type: 'object',
|
|
949
|
+
additionalProperties: false,
|
|
950
|
+
properties: {
|
|
951
|
+
sourceId: { type: 'string', required: true },
|
|
952
|
+
locator: { type: 'string', required: true },
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
kind: { type: 'string', required: true, enum: ['mcq', 'short'] },
|
|
957
|
+
prompt: { type: 'string', required: true },
|
|
958
|
+
options: { type: 'array', items: { type: 'string' } },
|
|
959
|
+
answer: { type: 'string' },
|
|
960
|
+
rubric: { type: 'string' },
|
|
961
|
+
explanation: { type: 'string' },
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
type: 'object',
|
|
971
|
+
additionalProperties: false,
|
|
972
|
+
properties: {
|
|
973
|
+
id: { type: 'string', required: true },
|
|
974
|
+
sourceRefs: {
|
|
975
|
+
type: 'array',
|
|
976
|
+
items: {
|
|
977
|
+
type: 'object',
|
|
978
|
+
additionalProperties: false,
|
|
979
|
+
properties: {
|
|
980
|
+
sourceId: { type: 'string', required: true },
|
|
981
|
+
locator: { type: 'string', required: true },
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
type: { type: 'string', required: true, enum: ['flashcard'] },
|
|
986
|
+
content: {
|
|
987
|
+
type: 'object',
|
|
988
|
+
additionalProperties: false,
|
|
989
|
+
properties: {
|
|
990
|
+
front: { type: 'string', required: true },
|
|
991
|
+
back: { type: 'string', required: true },
|
|
992
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
type: 'object',
|
|
999
|
+
additionalProperties: false,
|
|
1000
|
+
properties: {
|
|
1001
|
+
id: { type: 'string', required: true },
|
|
1002
|
+
sourceRefs: {
|
|
1003
|
+
type: 'array',
|
|
1004
|
+
items: {
|
|
1005
|
+
type: 'object',
|
|
1006
|
+
additionalProperties: false,
|
|
1007
|
+
properties: {
|
|
1008
|
+
sourceId: { type: 'string', required: true },
|
|
1009
|
+
locator: { type: 'string', required: true },
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
type: { type: 'string', required: true, enum: ['resource'] },
|
|
1014
|
+
content: {
|
|
1015
|
+
type: 'object',
|
|
1016
|
+
additionalProperties: false,
|
|
1017
|
+
properties: {
|
|
1018
|
+
title: { type: 'string', required: true },
|
|
1019
|
+
sourceId: { type: 'string' },
|
|
1020
|
+
locator: { type: 'string' },
|
|
1021
|
+
url: { type: 'string' },
|
|
1022
|
+
note: { type: 'string' },
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
type: 'object',
|
|
1029
|
+
additionalProperties: false,
|
|
1030
|
+
properties: {
|
|
1031
|
+
id: { type: 'string', required: true },
|
|
1032
|
+
sourceRefs: {
|
|
1033
|
+
type: 'array',
|
|
1034
|
+
items: {
|
|
1035
|
+
type: 'object',
|
|
1036
|
+
additionalProperties: false,
|
|
1037
|
+
properties: {
|
|
1038
|
+
sourceId: { type: 'string', required: true },
|
|
1039
|
+
locator: { type: 'string', required: true },
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
type: { type: 'string', required: true, enum: ['comparison'] },
|
|
1044
|
+
content: {
|
|
1045
|
+
type: 'object',
|
|
1046
|
+
additionalProperties: false,
|
|
1047
|
+
properties: {
|
|
1048
|
+
columns: { type: 'array', required: true, items: { type: 'string' } },
|
|
1049
|
+
rows: { type: 'array', required: true, items: { type: 'array', items: { type: 'string' } } },
|
|
1050
|
+
caption: { type: 'string' },
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
707
1055
|
],
|
|
708
1056
|
};
|
|
709
1057
|
/**
|
|
@@ -718,6 +1066,54 @@ const BLOCK_PARAM_SPEC = {
|
|
|
718
1066
|
* and the next unit follows. `replace` exists for revising a unit that was
|
|
719
1067
|
* wrong, not for regenerating a course.
|
|
720
1068
|
*/
|
|
1069
|
+
/**
|
|
1070
|
+
* Attach host-rendered MathML to the two block types that carry formulas.
|
|
1071
|
+
*
|
|
1072
|
+
* This runs before `BlockSchema.parse`, so an invalid formula block is refused
|
|
1073
|
+
* with the block index, while a bad inline expression in a text block is left
|
|
1074
|
+
* as source for the client's styled fallback.
|
|
1075
|
+
*/
|
|
1076
|
+
function prepareMath(candidate, index) {
|
|
1077
|
+
if (candidate === null || typeof candidate !== 'object')
|
|
1078
|
+
return candidate;
|
|
1079
|
+
const block = candidate;
|
|
1080
|
+
if (block.type === 'text' && block.content !== null && typeof block.content === 'object') {
|
|
1081
|
+
const content = block.content;
|
|
1082
|
+
if (typeof content.md !== 'string')
|
|
1083
|
+
return candidate;
|
|
1084
|
+
const math = renderMathFragments(content.md);
|
|
1085
|
+
const { math: _old, ...rest } = content;
|
|
1086
|
+
return { ...block, content: math.length === 0 ? rest : { ...rest, math } };
|
|
1087
|
+
}
|
|
1088
|
+
if (block.type === 'formula' && block.content !== null && typeof block.content === 'object') {
|
|
1089
|
+
const content = block.content;
|
|
1090
|
+
if (typeof content.tex !== 'string')
|
|
1091
|
+
return candidate;
|
|
1092
|
+
const mathml = renderTexToMathML(content.tex, content.display === true);
|
|
1093
|
+
return { ...block, content: { ...content, mathml } };
|
|
1094
|
+
}
|
|
1095
|
+
return candidate;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Source references are cross-table facts: the block schema can only check
|
|
1099
|
+
* their shape, so the write boundary checks that every cited source exists.
|
|
1100
|
+
* A dangling citation is refused rather than rendered as a broken footnote.
|
|
1101
|
+
*/
|
|
1102
|
+
function assertSources(state, block, index) {
|
|
1103
|
+
const references = block.sourceRefs ?? [];
|
|
1104
|
+
for (const reference of references) {
|
|
1105
|
+
const source = state.readSource(reference.sourceId);
|
|
1106
|
+
if (source === undefined || source.status !== 'ready') {
|
|
1107
|
+
throw new Error(`block[${index}] references unknown source "${reference.sourceId}" — add it with udt_source_add first`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (block.type === 'resource' && block.content.sourceId !== undefined) {
|
|
1111
|
+
const source = state.readSource(block.content.sourceId);
|
|
1112
|
+
if (source === undefined || source.status !== 'ready') {
|
|
1113
|
+
throw new Error(`block[${index}] references unknown source "${block.content.sourceId}" — add it with udt_source_add first`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
721
1117
|
function udtLessonUpdateTool(state) {
|
|
722
1118
|
return defineTool({
|
|
723
1119
|
name: 'udt_lesson_update',
|
|
@@ -725,19 +1121,26 @@ function udtLessonUpdateTool(state) {
|
|
|
725
1121
|
'The blocks appear in the panel beside the diagnosis map, so the learner reads them while ' +
|
|
726
1122
|
'answering in the chat — write for that surface, not as a chat message.\n' +
|
|
727
1123
|
'Block types: **text** {md}, **example** {title, steps[], takeaway?}, ' +
|
|
728
|
-
'**diagram** {format: ascii|mermaid, spec, caption?}, **check** {prompt, expect?, hint?}
|
|
1124
|
+
'**diagram** {format: ascii|mermaid, spec, caption?}, **check** {prompt, expect?, hint?}, ' +
|
|
1125
|
+
'**formula** {tex, display?, caption?}, **code** {language?, source, caption?}, ' +
|
|
1126
|
+
'**quiz** {title?, questions[]}, **flashcard** {front, back, tags?}, ' +
|
|
1127
|
+
'**resource** {title, sourceId?, locator?, url?, note?}, **comparison** {columns[], rows[][], caption?}.\n' +
|
|
729
1128
|
`At most ${MAX_BLOCKS_PER_UPDATE} blocks per call and ${MAX_BLOCKS_PER_LESSON} per lesson; a teaching unit is a few ` +
|
|
730
1129
|
'blocks, not a chapter.\n' +
|
|
731
1130
|
'**Write your first unit as soon as you know it** rather than composing the whole thing first: ' +
|
|
732
1131
|
'the blocks appear in the learner\'s surface the moment you send them, so a short first unit ' +
|
|
733
1132
|
'turns a silent wait into visible progress. Send more later with append.\n' +
|
|
734
1133
|
'Default mode "append" adds to the current lesson; use "replace" only to correct what is there.\n' +
|
|
735
|
-
'A check block has no input box: the surface displays the question and the learner answers in the chat.'
|
|
1134
|
+
'A check block has no input box: the surface displays the question and the learner answers in the chat. ' +
|
|
1135
|
+
'An explicit nodeId write may select its project only when this conversation has no selection. ' +
|
|
1136
|
+
'It never replaces new/project selection; check projectBinding in the result. ' +
|
|
1137
|
+
'For planned teaching, read udt_path_get, explicitly bind its node with udt_course_run_update and supply active attemptId. Content is saved per conversation/attempt with history; stale attempts are rejected.',
|
|
736
1138
|
parameters: {
|
|
737
1139
|
nodeId: {
|
|
738
1140
|
type: 'string',
|
|
739
|
-
description: 'Node to write for. Omit to use
|
|
1141
|
+
description: 'Node to write for. Omit to use this conversation\'s current focus (see udt_status).',
|
|
740
1142
|
},
|
|
1143
|
+
attemptId: { type: 'string', description: 'Required for planned teaching: active attemptId from udt_path_get.' },
|
|
741
1144
|
title: { type: 'string', description: 'Lesson title; defaults to the node title.' },
|
|
742
1145
|
mode: {
|
|
743
1146
|
type: 'string',
|
|
@@ -762,34 +1165,40 @@ function udtLessonUpdateTool(state) {
|
|
|
762
1165
|
mode: { type: 'string', required: true },
|
|
763
1166
|
blockCount: { type: 'integer', required: true, description: 'Blocks in the lesson after this call.' },
|
|
764
1167
|
addedCount: { type: 'integer', required: true },
|
|
1168
|
+
reviewCardCount: { type: 'integer', required: true, description: 'Flashcards now linked to review cards.' },
|
|
1169
|
+
markdown: { type: 'string', required: true, description: 'Plain Markdown projection for harnesses without the panel.' },
|
|
765
1170
|
origin: { type: 'string', required: true },
|
|
1171
|
+
...PROJECT_BINDING_PROPERTIES,
|
|
766
1172
|
},
|
|
767
1173
|
},
|
|
768
1174
|
render: (_args, value) => [
|
|
769
1175
|
{
|
|
770
1176
|
type: 'text',
|
|
771
|
-
text:
|
|
1177
|
+
text: JSON.stringify(value),
|
|
772
1178
|
},
|
|
773
1179
|
],
|
|
774
1180
|
},
|
|
775
|
-
execute: async (args) => {
|
|
1181
|
+
execute: async (args, exec) => {
|
|
776
1182
|
const input = args;
|
|
777
1183
|
const mode = input.mode ?? 'append';
|
|
778
|
-
const
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1184
|
+
const sessionId = sessionOf(exec);
|
|
1185
|
+
const project = state.resolveProject(sessionId);
|
|
1186
|
+
const focused = sessionId === undefined ? undefined : state.activeFocus(sessionId);
|
|
1187
|
+
const planned = sessionId && project.course ? activeAttempt(state.readCourseRun(project.course.id, sessionId)) : undefined;
|
|
1188
|
+
if (planned && ['requested', 'working'].includes(planned.status) && !input.attemptId)
|
|
1189
|
+
throw new Error('planned-lesson-needs-attemptId: read udt_path_get and bind its diagnosed node');
|
|
1190
|
+
const attempt = input.attemptId && project.course ? requireAttempt(state, project.course.id, sessionId, input.attemptId) : undefined;
|
|
1191
|
+
if (input.attemptId && !attempt)
|
|
1192
|
+
throw new Error('no-active-course-attempt');
|
|
1193
|
+
const nodeId = input.nodeId ?? attempt?.nodeId ?? focused?.node.id;
|
|
784
1194
|
if (nodeId === undefined) {
|
|
785
1195
|
throw new Error('no nodeId given and no learning focus is active; call udt_status to see the focus');
|
|
786
1196
|
}
|
|
787
1197
|
const node = state.readNode(nodeId);
|
|
788
1198
|
if (node === undefined)
|
|
789
1199
|
throw new Error(`no node "${nodeId}"`);
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
1200
|
+
const courseId = resolveCourseId(state, node.courseId, project);
|
|
1201
|
+
requireProjectMatch(project, courseId);
|
|
793
1202
|
if (!Array.isArray(input.blocks) || input.blocks.length === 0) {
|
|
794
1203
|
throw new Error('`blocks` must be a non-empty array');
|
|
795
1204
|
}
|
|
@@ -798,9 +1207,11 @@ function udtLessonUpdateTool(state) {
|
|
|
798
1207
|
'Write one teaching unit, not a chapter.');
|
|
799
1208
|
}
|
|
800
1209
|
// Strict, and it names the offending block: a model that sent one bad
|
|
801
|
-
// block should not have to guess which.
|
|
802
|
-
|
|
803
|
-
|
|
1210
|
+
// block should not have to guess which. Host-owned MathML is generated
|
|
1211
|
+
// here so the model never supplies markup the client would trust.
|
|
1212
|
+
const prepared = input.blocks.map((candidate, index) => prepareMath(candidate, index));
|
|
1213
|
+
const parsed = prepared.map((candidate, index) => {
|
|
1214
|
+
const result = KnownBlockSchema.safeParse(candidate);
|
|
804
1215
|
if (!result.success) {
|
|
805
1216
|
const issue = result.error.issues[0];
|
|
806
1217
|
const where = issue === undefined ? '' : `${issue.path.join('.') || '(root)'}: ${issue.message}`;
|
|
@@ -808,31 +1219,44 @@ function udtLessonUpdateTool(state) {
|
|
|
808
1219
|
}
|
|
809
1220
|
return result.data;
|
|
810
1221
|
});
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
if (blocks.length > MAX_BLOCKS_PER_LESSON) {
|
|
815
|
-
throw new Error(`a lesson holds at most ${MAX_BLOCKS_PER_LESSON} blocks; this would make ${blocks.length}. ` +
|
|
816
|
-
'Replace the lesson or start a new node.');
|
|
1222
|
+
parsed.forEach((block, index) => assertSources(state, block, index));
|
|
1223
|
+
if (Buffer.byteLength(JSON.stringify(parsed), 'utf8') > MAX_LESSON_BYTES) {
|
|
1224
|
+
throw new Error(`blocks are too large; the whole unit must stay under ${MAX_LESSON_BYTES} bytes`);
|
|
817
1225
|
}
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1226
|
+
const buildLesson = (existing) => {
|
|
1227
|
+
const blocks = mode === 'replace' ? parsed : [...(existing?.blocks ?? []), ...parsed];
|
|
1228
|
+
if (blocks.length > MAX_BLOCKS_PER_LESSON) {
|
|
1229
|
+
throw new Error(`a lesson holds at most ${MAX_BLOCKS_PER_LESSON} blocks; this would make ${blocks.length}. ` +
|
|
1230
|
+
'Replace the lesson or start a new node.');
|
|
1231
|
+
}
|
|
1232
|
+
const now = nowIso();
|
|
1233
|
+
return LessonSchema.parse({
|
|
1234
|
+
id: existing?.id ?? (attempt ? `${attempt.id}:${nodeId}:lesson` : `${nodeId}:lesson`),
|
|
1235
|
+
courseId,
|
|
1236
|
+
nodeId,
|
|
1237
|
+
title: input.title ?? existing?.title ?? state.readNode(nodeId).title,
|
|
1238
|
+
blocks,
|
|
1239
|
+
origin: 'tutor',
|
|
1240
|
+
createdAt: existing?.createdAt ?? now,
|
|
1241
|
+
updatedAt: now,
|
|
1242
|
+
});
|
|
1243
|
+
};
|
|
1244
|
+
const lesson = attempt && sessionId
|
|
1245
|
+
? await writeCourseTeaching(state, courseId, sessionId, attempt.id, nodeId, buildLesson)
|
|
1246
|
+
: await state.mutateLesson(courseId, nodeId, (existing) => {
|
|
1247
|
+
requireProjectMatch(state.resolveProject(sessionId), courseId);
|
|
1248
|
+
const current = sessionId ? activeAttempt(state.readCourseRun(courseId, sessionId)) : undefined;
|
|
1249
|
+
if (current && ['requested', 'working'].includes(current.status))
|
|
1250
|
+
throw new Error('planned-lesson-needs-attemptId: read udt_path_get and bind its diagnosed node');
|
|
1251
|
+
return buildLesson(existing);
|
|
1252
|
+
});
|
|
1253
|
+
const now = lesson.updatedAt;
|
|
830
1254
|
// The tutor has produced teaching for this node: the handoff that was
|
|
831
1255
|
// waiting on it is now ready, and its timing chain closes.
|
|
832
1256
|
// Atomic and monotonic: a lesson can only move a handoff forward, never
|
|
833
1257
|
// back, so a late activity event cannot undo it.
|
|
834
|
-
if (state.readHandoff(nodeId) !== undefined) {
|
|
835
|
-
await state.updateHandoff(nodeId, (current) => withLesson(current, now));
|
|
1258
|
+
if (sessionId !== undefined && state.readHandoff(sessionId, nodeId) !== undefined) {
|
|
1259
|
+
await state.updateHandoff(sessionId, nodeId, (current) => withLesson(current, now));
|
|
836
1260
|
}
|
|
837
1261
|
return {
|
|
838
1262
|
lessonId: lesson.id,
|
|
@@ -841,7 +1265,10 @@ function udtLessonUpdateTool(state) {
|
|
|
841
1265
|
mode,
|
|
842
1266
|
blockCount: lesson.blocks.length,
|
|
843
1267
|
addedCount: parsed.length,
|
|
1268
|
+
reviewCardCount: (await syncLessonFlashcards(state, { courseId, nodeId, lesson, now })).length,
|
|
1269
|
+
markdown: blocksMarkdown(lesson.blocks),
|
|
844
1270
|
origin: lesson.origin,
|
|
1271
|
+
...await state.adoptProject(sessionId, courseId, now, project),
|
|
845
1272
|
};
|
|
846
1273
|
},
|
|
847
1274
|
presentCall: (args) => ({
|
|
@@ -853,6 +1280,59 @@ function udtLessonUpdateTool(state) {
|
|
|
853
1280
|
});
|
|
854
1281
|
}
|
|
855
1282
|
/* -------------------------------------------------------------------------- */
|
|
1283
|
+
/* udt_quiz_update */
|
|
1284
|
+
/* -------------------------------------------------------------------------- */
|
|
1285
|
+
/**
|
|
1286
|
+
* Record a learner's quiz/flashcard submission. This is storage, not judgement:
|
|
1287
|
+
* it never changes a node state, never marks mastery and never advances a lesson.
|
|
1288
|
+
* The tutor can read the submission through udt_path_get and decide what to say.
|
|
1289
|
+
*/
|
|
1290
|
+
function udtQuizUpdateTool(state) {
|
|
1291
|
+
return defineTool({
|
|
1292
|
+
name: 'udt_quiz_update',
|
|
1293
|
+
description: 'Record the learner\'s answers for a quiz or flashcard block on the active course attempt. ' +
|
|
1294
|
+
'This only records what was submitted; it does NOT grade, does not change a node state, and does not advance the lesson. ' +
|
|
1295
|
+
'Use the active attemptId from udt_path_get; stale or inactive attempts are refused. ' +
|
|
1296
|
+
'For a quiz, answer each question id with one of the offered options (or free text for a short question). ' +
|
|
1297
|
+
'For a flashcard, answer with the card block id and value again | got-it.',
|
|
1298
|
+
parameters: {
|
|
1299
|
+
attemptId: { type: 'string', required: true },
|
|
1300
|
+
blockId: { type: 'string', required: true },
|
|
1301
|
+
answers: {
|
|
1302
|
+
type: 'array', required: true,
|
|
1303
|
+
items: {
|
|
1304
|
+
type: 'object', additionalProperties: false,
|
|
1305
|
+
properties: {
|
|
1306
|
+
questionId: { type: 'string', required: true },
|
|
1307
|
+
value: { type: 'string', required: true },
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1312
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
1313
|
+
execute: async (args, exec) => {
|
|
1314
|
+
const input = args;
|
|
1315
|
+
const sessionId = sessionOf(exec);
|
|
1316
|
+
if (sessionId === undefined)
|
|
1317
|
+
throw new Error('quiz-write-requires-a-conversation');
|
|
1318
|
+
const project = state.resolveProject(sessionId);
|
|
1319
|
+
if (project.course === undefined)
|
|
1320
|
+
throw new Error('no-selected-project: open a project first');
|
|
1321
|
+
const path = state.readPath(project.course.id);
|
|
1322
|
+
if (path === undefined)
|
|
1323
|
+
throw new Error('no-learning-path: ask for a route first');
|
|
1324
|
+
const submission = AnswerSubmissionSchema.parse(input);
|
|
1325
|
+
const view = await recordBlockResponse(state, {
|
|
1326
|
+
courseId: project.course.id,
|
|
1327
|
+
sessionId,
|
|
1328
|
+
expectedRevision: path.revision,
|
|
1329
|
+
...submission,
|
|
1330
|
+
});
|
|
1331
|
+
return JSON.parse(JSON.stringify(view));
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
/* -------------------------------------------------------------------------- */
|
|
856
1336
|
/* udt_decide_next */
|
|
857
1337
|
/* -------------------------------------------------------------------------- */
|
|
858
1338
|
/**
|
|
@@ -928,12 +1408,16 @@ function udtDecideNextTool(state) {
|
|
|
928
1408
|
},
|
|
929
1409
|
],
|
|
930
1410
|
},
|
|
931
|
-
execute: async (args) => {
|
|
1411
|
+
execute: async (args, exec) => {
|
|
932
1412
|
const input = args;
|
|
933
|
-
const
|
|
934
|
-
|
|
1413
|
+
const sessionId = sessionOf(exec);
|
|
1414
|
+
if (sessionId === undefined) {
|
|
1415
|
+
throw new Error('this call has no conversation; a decision must be recorded in one');
|
|
1416
|
+
}
|
|
1417
|
+
const focused = state.activeFocus(sessionId);
|
|
1418
|
+
const courseId = state.resolveProject(sessionId).course?.id;
|
|
935
1419
|
if (courseId === undefined) {
|
|
936
|
-
throw new Error('no
|
|
1420
|
+
throw new Error('this conversation has no selected project; record a goal or select a project and start learning on a node first');
|
|
937
1421
|
}
|
|
938
1422
|
const fromNodeId = input.fromNodeId ?? focused?.node.id;
|
|
939
1423
|
if (fromNodeId === undefined) {
|
|
@@ -957,6 +1441,7 @@ function udtDecideNextTool(state) {
|
|
|
957
1441
|
...(input.targetNodeId === undefined ? {} : { targetNodeId: input.targetNodeId }),
|
|
958
1442
|
reason: input.reason,
|
|
959
1443
|
now: nowIso(),
|
|
1444
|
+
sessionId,
|
|
960
1445
|
});
|
|
961
1446
|
return {
|
|
962
1447
|
fromNodeId: nextStep.fromNodeId,
|
|
@@ -982,13 +1467,25 @@ function udtDecideNextTool(state) {
|
|
|
982
1467
|
*
|
|
983
1468
|
* @param host - the context slice carrying the tools registry.
|
|
984
1469
|
* @param state - the open persistence handle the tools read from and write to.
|
|
1470
|
+
* @returns how many tools were registered, so the loader log cannot drift from
|
|
1471
|
+
* the registry itself (it used to say "4" while nine were registered).
|
|
985
1472
|
*/
|
|
986
1473
|
export function registerTools(host, state) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1474
|
+
const core = [
|
|
1475
|
+
udtStatusTool(state),
|
|
1476
|
+
udtGoalCreateTool(state),
|
|
1477
|
+
udtMapGetTool(state),
|
|
1478
|
+
udtMapUpdateTool(state),
|
|
1479
|
+
udtLessonUpdateTool(state),
|
|
1480
|
+
udtQuizUpdateTool(state),
|
|
1481
|
+
udtDecideNextTool(state),
|
|
1482
|
+
];
|
|
1483
|
+
const planning = planningTools(state);
|
|
1484
|
+
const sources = sourceTools(state);
|
|
1485
|
+
const plan = planTools(state);
|
|
1486
|
+
const review = reviewTools(state);
|
|
1487
|
+
for (const tool of [...core, ...planning, ...sources, ...plan, ...review])
|
|
1488
|
+
host.tools.register(tool);
|
|
1489
|
+
return core.length + planning.length + sources.length + plan.length + review.length;
|
|
993
1490
|
}
|
|
994
1491
|
//# sourceMappingURL=tools.js.map
|