@qlm/learnlog-mcp 0.0.0-stage → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +43 -0
  4. package/README.md +121 -2
  5. package/data/SOURCE_CORRECTION.md +9 -0
  6. package/data/THIRD_PARTY_DATA.md +148 -0
  7. package/data/examples/chain-tpc-clinical-v2-tampered.json +97 -0
  8. package/data/examples/chain-tpc-clinical-v2.json +95 -0
  9. package/data/examples/evidence-record-clean.json +173 -0
  10. package/data/examples/evidence-record-tampered.json +173 -0
  11. package/data/labpath-worlds.json +107 -0
  12. package/data/preprint/README.md +10 -0
  13. package/data/preprint/assistments_episodes.json +37 -0
  14. package/data/preprint/cross_source_episodes.json +63 -0
  15. package/data/provenance/oatutor.json +84 -0
  16. package/data/provenance/source-audit.json +69 -0
  17. package/data/provenance/tutormoments.json +91 -0
  18. package/data/samples/explicit-assistance-SYNTHETIC.jsonl +5 -0
  19. package/data/samples/oatutor-real-participants.csv +13 -0
  20. package/data/samples/sandpiper-SYNTHETIC.csv +9 -0
  21. package/data/samples/tutormoments-real-transcripts.jsonl +2 -0
  22. package/dist/bin.js +6 -0
  23. package/dist/episodes.js +488 -0
  24. package/dist/files.js +39 -0
  25. package/dist/package.js +14 -0
  26. package/dist/py.js +419 -0
  27. package/dist/respond.js +47 -0
  28. package/dist/sealed.js +31 -0
  29. package/dist/server.js +115 -0
  30. package/dist/stages.js +35 -0
  31. package/dist/tools/formats.js +35 -0
  32. package/dist/tools/inventory.js +179 -0
  33. package/dist/tools/learning-graph.js +111 -0
  34. package/dist/tools/misconceptions.js +54 -0
  35. package/dist/tools/preprint.js +241 -0
  36. package/dist/tools/qlm-datasets.js +56 -0
  37. package/dist/tools/transform.js +61 -0
  38. package/dist/tools/validate.js +47 -0
  39. package/dist/tools/verify-credential.js +155 -0
  40. package/dist/tools/verify-evidence.js +51 -0
  41. package/dist/tools/worlds.js +24 -0
  42. package/dist/transformer.js +92 -0
  43. package/dist/zip.js +71 -0
  44. package/package.json +66 -4
  45. package/skills/learnlog-assistments/SKILL.md +38 -0
  46. package/skills/learnlog-export-to-action/SKILL.md +31 -0
  47. package/skills/learnlog-learning-commons/SKILL.md +33 -0
  48. package/skills/learnlog-nto-sandpiper/SKILL.md +31 -0
  49. package/vendor/PROVENANCE.json +259 -0
  50. package/vendor/qlm-measure/checks.d.ts +24 -0
  51. package/vendor/qlm-measure/checks.js +42 -0
  52. package/vendor/qlm-measure/cli.d.ts +17 -0
  53. package/vendor/qlm-measure/cli.js +461 -0
  54. package/vendor/qlm-measure/rules.d.ts +10 -0
  55. package/vendor/qlm-measure/rules.js +46 -0
  56. package/vendor/qlm-measure/schemes/index.d.ts +133 -0
  57. package/vendor/qlm-measure/schemes/index.js +339 -0
  58. package/vendor/qlm-measure/schemes/node.d.ts +8 -0
  59. package/vendor/qlm-measure/schemes/node.js +15 -0
  60. package/vendor/qlm-measure/schemes/products.d.ts +38 -0
  61. package/vendor/qlm-measure/schemes/products.js +725 -0
  62. package/vendor/qlm-measure/verifier/hash.d.ts +27 -0
  63. package/vendor/qlm-measure/verifier/hash.js +163 -0
  64. package/vendor/qlm-measure/verifier/verify-record.d.ts +22 -0
  65. package/vendor/qlm-measure/verifier/verify-record.js +123 -0
  66. package/vendor/qlm-measure/verifier-v03.d.ts +24 -0
  67. package/vendor/qlm-measure/verifier-v03.js +205 -0
  68. package/vendor/transformer/LICENSE.txt +29 -0
  69. package/vendor/transformer/cross-source-analysis-data.d.mts +3 -0
  70. package/vendor/transformer/cross-source-analysis-data.mjs +238 -0
  71. package/vendor/transformer/engine.d.mts +61 -0
  72. package/vendor/transformer/engine.mjs +445 -0
  73. package/vendor/transformer/profiles.mjs +309 -0
  74. package/vendor/transformer/research-semantics.mjs +1453 -0
@@ -0,0 +1,488 @@
1
+ /**
2
+ * The preprint's counting rules, ported line by line from the published Python so they run inside
3
+ * this server with no Python installed:
4
+ *
5
+ * research/learnlog-episodes/cross_source.py Zenodo 17551130 and IDETrace episode classes
6
+ * research/learnlog-episodes/assistments_episodes.py ASSISTments 2012-13 episode classes
7
+ * tools/qlm-transformer/.../analysis/aggregate.py the request inventory (zenodo_requests,
8
+ * tutortrace_requests: native counts only)
9
+ *
10
+ * The tests run the original scripts and this port on the same files and require identical output.
11
+ * Where a script would stop with a Python exception, this port stops with a plain message instead.
12
+ * Where cross_source.py would divide by zero, the percentage is null.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { createReadStream } from "node:fs";
16
+ import { PyCsv, compareCodePoints, findPy, loadsPyJson, parsePyJson, pyFloat, pyIntOfFloat, pyIsoTimestamp, pyRound, pyTypeName } from "./py.js";
17
+ import { zipEntries, zipRead } from "./zip.js";
18
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
19
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
20
+ const inc = (c, k, by = 1) => {
21
+ c[k] = (c[k] ?? 0) + by;
22
+ };
23
+ const sortedCounts = (c) => {
24
+ const out = {};
25
+ for (const k of [...c.keys()].sort(compareCodePoints))
26
+ out[k] = c.get(k);
27
+ return out;
28
+ };
29
+ const pct = (num, den) => (den === 0 ? null : pyRound(num / den, 1));
30
+ const CLICKS = new Set(["Hint button is clicked", "Hint retry is clicked"]);
31
+ const SHOWN = new Set(["Code banner shown", "Text banner shown", "Show code hint is clicked", "Code hint is accepted"]);
32
+ const COPY_INFO = new Set(["EditorCopy", "$Copy", "EditorCut", "$Cut"]);
33
+ const PASTE_INFO = new Set(["EditorPaste", "$Paste"]);
34
+ /** Python's csv.DictReader access: a short row, or a column the header lacks, gives None (null). */
35
+ const cell = (row, i) => (i < 0 ? null : (row[i] ?? null));
36
+ function eventsAt(text, at, member) {
37
+ const { value } = parsePyJson(text, at);
38
+ if (!Array.isArray(value))
39
+ throw new Error(`eventsData in ${member} is not a JSON array`);
40
+ for (const e of value)
41
+ if (!isObj(e))
42
+ throw new Error(`an event in ${member} is not an object`);
43
+ return value;
44
+ }
45
+ export function analyzeZenodo(zip) {
46
+ const entries = zipEntries(zip);
47
+ const members = entries.filter((e) => e.name.startsWith("hints_dataset/") && e.name.endsWith(".html")).sort((a, b) => compareCodePoints(a.name, b.name));
48
+ const zeps = [];
49
+ let firstIsClick = 0;
50
+ let textBanner = 0;
51
+ let ratedOnly = 0;
52
+ const clicksPer = {};
53
+ let nativeInputs = 0;
54
+ const labelCounts = new Map();
55
+ const subtypes = new Map();
56
+ for (const entry of members) {
57
+ const bytes = zipRead(zip, entry);
58
+ let text;
59
+ try {
60
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
61
+ }
62
+ catch {
63
+ throw new Error(`${entry.name} is not valid UTF-8, which aggregate.py's strict decoding refuses`);
64
+ }
65
+ // cross_source.py
66
+ const at = findPy(text, "eventsData");
67
+ if (at < 0)
68
+ throw new Error(`${entry.name} has no eventsData assignment`);
69
+ const ev = eventsAt(text, at, entry.name);
70
+ const logs = ev.filter((e) => e.type === "Log");
71
+ const d = logs.map((e) => {
72
+ if (typeof e.description !== "string")
73
+ throw new Error(`a Log event in ${entry.name} has no text description`);
74
+ return e.description;
75
+ });
76
+ if (!logs.length)
77
+ throw new Error(`${entry.name} has no Log rows, so cross_source.py cannot read its request time`);
78
+ const S = new Set(d);
79
+ const shownEvent = logs.find((e) => SHOWN.has(e.description));
80
+ const shownT = shownEvent ? pyIsoTimestamp(String(shownEvent.timestamp).replaceAll("Z", "+00:00")) : null;
81
+ if (CLICKS.has(d[0]))
82
+ firstIsClick++;
83
+ inc(clicksPer, d.filter((x) => CLICKS.has(x)).length);
84
+ if (S.has("Text banner shown"))
85
+ textBanner++;
86
+ if (shownT !== null && !S.has("Code hint is accepted") && !S.has("Show code hint is clicked") && (S.has("Like") || S.has("Dislike")))
87
+ ratedOnly++;
88
+ const o = S.has("Code hint is accepted") ? "shown_acted_accepted" : S.has("Show code hint is clicked") ? "shown_acted_revealed_not_accepted" : shownT !== null ? "shown_no_further_action_or_rating_only" : S.has("Error banner shown") ? "not_shown_error" : "not_shown_silent";
89
+ zeps.push({ who: entry.name.split("/")[1], t_req: pyIsoTimestamp(String(logs[0].timestamp).replaceAll("Z", "+00:00")), t_shown: shownT, o, retry: d[0] === "Hint retry is clicked" });
90
+ // aggregate.py zenodo_requests
91
+ const constAt = findPy(text, "constEventsData");
92
+ if (constAt < 0)
93
+ throw new Error(`${entry.name} has no "const eventsData =" assignment, which aggregate.py's pattern requires`);
94
+ const native = constAt === at ? ev : eventsAt(text, constAt, entry.name);
95
+ for (const e of native) {
96
+ nativeInputs++;
97
+ if (e.type === "Log") {
98
+ const label = e.description;
99
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
100
+ if (CLICKS.has(label))
101
+ subtypes.set(label, (subtypes.get(label) ?? 0) + 1);
102
+ }
103
+ }
104
+ }
105
+ const by = new Map();
106
+ for (const e of zeps)
107
+ by.set(e.who, [...(by.get(e.who) ?? []), e]);
108
+ let again = 0;
109
+ let againRetry = 0;
110
+ for (const list of by.values()) {
111
+ list.sort((a, b) => a.t_req - b.t_req);
112
+ for (let k = 0; k + 1 < list.length; k++) {
113
+ const [a, b] = [list[k], list[k + 1]];
114
+ if (a.t_shown !== null && b.t_req - a.t_shown >= 0 && b.t_req - a.t_shown <= 300) {
115
+ again++;
116
+ if (b.retry)
117
+ againRetry++;
118
+ }
119
+ }
120
+ }
121
+ const outcomes = {};
122
+ for (const e of zeps)
123
+ inc(outcomes, e.o);
124
+ const shown = zeps.filter((e) => e.t_shown !== null).length;
125
+ // raw activity file: copies and pastes compared by timestamp (the v0.6 rule)
126
+ const activity = entries.find((e) => e.name === "raw_dataset/activitydata.csv");
127
+ if (!activity)
128
+ throw new Error("the archive has no raw_dataset/activitydata.csv, which cross_source.py reads");
129
+ const csvText = new TextDecoder("utf-8", { ignoreBOM: true }).decode(zipRead(zip, activity)).replace(/\r\n?/g, "\n");
130
+ const parser = new PyCsv();
131
+ const table = [...parser.push(csvText), ...parser.end()];
132
+ const rawIds = new Set();
133
+ const pasteIds = new Set();
134
+ const copies = new Map();
135
+ const pastes = [];
136
+ let rawPastes = 0;
137
+ const [header = [], ...data] = table;
138
+ const [ridCol, typeCol, infoCol, dateCol] = ["research_id", "type", "info", "date"].map((n) => header.indexOf(n));
139
+ for (const row of data) {
140
+ const rid = cell(row, ridCol);
141
+ rawIds.add(rid);
142
+ const info = cell(row, infoCol);
143
+ const action = cell(row, typeCol) === "Action" && info !== null;
144
+ const isCopy = action && COPY_INFO.has(info);
145
+ const isPaste = action && PASTE_INFO.has(info);
146
+ if (!isCopy && !isPaste)
147
+ continue;
148
+ if (dateCol < 0)
149
+ throw new Error("raw_dataset/activitydata.csv has no date column");
150
+ const t = pyIsoTimestamp(cell(row, dateCol) ?? "");
151
+ if (isCopy)
152
+ copies.set(rid, [...(copies.get(rid) ?? []), t]);
153
+ else {
154
+ rawPastes++;
155
+ pasteIds.add(rid);
156
+ pastes.push([rid, t]);
157
+ }
158
+ }
159
+ let pastesNoCopy = 0;
160
+ for (const [rid, t] of pastes)
161
+ if (!(copies.get(rid) ?? []).some((c) => t - c >= 0 && t - c <= 600))
162
+ pastesNoCopy++;
163
+ const hintIds = new Set(by.keys());
164
+ const input = sha256(zip);
165
+ const subtypeList = [...subtypes.keys()].sort(compareCodePoints).map((label) => ({ label, count: subtypes.get(label) }));
166
+ return {
167
+ input_sha256: input,
168
+ inventory: {
169
+ native_input_count: nativeInputs,
170
+ native_assistance_log_count: [...labelCounts.values()].reduce((a, b) => a + b, 0),
171
+ native_log_label_counts: sortedCounts(labelCounts),
172
+ source_file_count: members.length,
173
+ input_sha256: input,
174
+ assistance_request_observations: subtypeList.reduce((a, s) => a + s.count, 0),
175
+ help_request_subtypes: subtypeList,
176
+ },
177
+ episodes: {
178
+ requests: zeps.length,
179
+ outcomes,
180
+ shown,
181
+ not_shown: zeps.length - shown,
182
+ asked_again_within_300s_of_shown: again,
183
+ hint_learner_folders: hintIds.size,
184
+ raw_activity_ids: rawIds.size,
185
+ raw_ids_linked_to_hint_folders: [...hintIds].filter((h) => rawIds.has(h)).length,
186
+ raw_paste_actions: rawPastes,
187
+ raw_ids_with_paste: pasteIds.size,
188
+ },
189
+ checks: {
190
+ zenodo_reports_whose_first_log_row_is_a_click: firstIsClick,
191
+ zenodo_clicks_per_report: clicksPer,
192
+ zenodo_reports_with_text_banner: textBanner,
193
+ zenodo_shown_rated_only: ratedOnly,
194
+ zenodo_asked_again_where_next_request_is_retry: againRetry,
195
+ zenodo_raw_pastes_without_copy_or_cut_in_prior_600s: pastesNoCopy,
196
+ },
197
+ };
198
+ }
199
+ const EDIT = new Set(["CODE_TYPE", "CODE_PASTE", "CODE_DELETE", "CODE_DELETE_SELECTION", "CODE_INDENT", "CODE_UNDO"]);
200
+ const IN_TOOL_COPY = new Set(["CODE_COPY", "TERMINAL_COPY", "TASK_COPY"]);
201
+ export function analyzeIdetrace(text) {
202
+ const d = loadsPyJson(text);
203
+ if (!isObj(d))
204
+ throw new Error("the IDETrace deployment file must be a JSON object of records");
205
+ const records = [];
206
+ for (const [key, body] of Object.entries(d)) {
207
+ const events = isObj(body) ? body.events : undefined;
208
+ if (!Array.isArray(events))
209
+ throw new Error(`record ${key} has no events array`);
210
+ for (const e of events) {
211
+ if (!isObj(e))
212
+ throw new Error(`an event in record ${key} is not an object`);
213
+ if (typeof e.type !== "string")
214
+ throw new Error(`an event in record ${key} has no text type`);
215
+ if (typeof e.timestamp !== "number")
216
+ throw new Error(`an event in record ${key} has no numeric timestamp`);
217
+ if (e.type === "TEST_CASE_RESULT" && !isObj(e.payload))
218
+ throw new Error(`a TEST_CASE_RESULT event in record ${key} has no payload object`);
219
+ }
220
+ records.push([key, events]);
221
+ }
222
+ const ts = (e) => e.timestamp;
223
+ const ic = {};
224
+ let again = 0, asked = 0, never = 0, neverPass = 0, cpaste = 0, cpasteNoCopy = 0, chatpaste = 0;
225
+ const passTypes = {};
226
+ const labelCounts = new Map();
227
+ let total = 0;
228
+ let queries = 0;
229
+ for (const [, events] of records) {
230
+ // aggregate.py tutortrace_requests: native order, every event counted by type
231
+ for (const e of events) {
232
+ total++;
233
+ labelCounts.set(e.type, (labelCounts.get(e.type) ?? 0) + 1);
234
+ if (e.type === "CHAT_QUERY")
235
+ queries++;
236
+ if (e.type === "TEST_CASE_RESULT")
237
+ inc(passTypes, pyTypeName(e.payload, "passrate"));
238
+ }
239
+ // cross_source.py
240
+ const ev = [...events].sort((a, b) => ts(a) - ts(b));
241
+ const Q = ev.flatMap((e, i) => (e.type === "CHAT_QUERY" ? [i] : []));
242
+ if (Q.length)
243
+ asked++;
244
+ else {
245
+ never++;
246
+ if (ev.some((e) => e.type === "TEST_CASE_RESULT" && e.payload.passrate === 100))
247
+ neverPass++;
248
+ }
249
+ for (let k = 0; k < Q.length; k++) {
250
+ const qi = Q[k];
251
+ const nxt = k + 1 < Q.length ? Q[k + 1] : ev.length;
252
+ let ri = null;
253
+ for (let i = qi + 1; i < nxt; i++)
254
+ if (ev[i].type === "CHAT_RESPONSE") {
255
+ ri = i;
256
+ break;
257
+ }
258
+ if (ri === null) {
259
+ inc(ic, "not_shown");
260
+ continue;
261
+ }
262
+ const t = ts(ev[ri]);
263
+ const edited = ev.slice(ri + 1).some((e) => EDIT.has(e.type) && ts(e) - t <= 120000);
264
+ inc(ic, edited ? "shown_code_edit_within_120s" : "shown_no_code_edit_within_120s");
265
+ if (k + 1 < Q.length) {
266
+ const gap = ts(ev[Q[k + 1]]) - t;
267
+ if (gap >= 0 && gap <= 300000)
268
+ again++;
269
+ }
270
+ }
271
+ ev.forEach((e, i) => {
272
+ if (e.type === "CODE_PASTE") {
273
+ cpaste++;
274
+ if (!ev.slice(0, i).some((c) => IN_TOOL_COPY.has(c.type) && ts(e) - ts(c) <= 600000))
275
+ cpasteNoCopy++;
276
+ }
277
+ if (e.type === "CHAT_PASTE")
278
+ chatpaste++;
279
+ });
280
+ }
281
+ const requests = Object.values(ic).reduce((a, b) => a + b, 0);
282
+ const notShown = ic.not_shown ?? 0;
283
+ const outcomes = { ...ic };
284
+ const input = sha256(text);
285
+ return {
286
+ input_sha256: input,
287
+ inventory: { native_input_count: total, source_file_count: 1, native_event_label_counts: sortedCounts(labelCounts), input_sha256: input, assistance_request_observations: queries },
288
+ episodes: {
289
+ records: records.length,
290
+ records_asked: asked,
291
+ records_never_asked: never,
292
+ never_asked_and_passed_all_tests: neverPass,
293
+ requests,
294
+ outcomes,
295
+ shown: requests - notShown,
296
+ not_shown: notShown,
297
+ asked_again_within_300s_of_shown: again,
298
+ code_pastes: cpaste,
299
+ code_pastes_without_in_tool_copy_600s: cpasteNoCopy,
300
+ chat_pastes: chatpaste,
301
+ },
302
+ checks: { idetrace_passrate_types: passTypes },
303
+ };
304
+ }
305
+ /** cross_source.py's output object, for whichever of the two sources were given; pooled needs both. */
306
+ export function crossSourceEpisodes(z, i) {
307
+ const inputs = {};
308
+ const out = { inputs };
309
+ const checks = {};
310
+ if (z) {
311
+ inputs.zenodo_sha256 = z.input_sha256;
312
+ out.zenodo = z.episodes;
313
+ Object.assign(checks, z.checks);
314
+ }
315
+ if (i) {
316
+ inputs.idetrace_sha256 = i.input_sha256;
317
+ out.idetrace = i.episodes;
318
+ Object.assign(checks, i.checks);
319
+ }
320
+ if (z && i) {
321
+ const zr = z.episodes.requests, zs = z.episodes.shown, ir = i.episodes.requests, is = i.episodes.shown;
322
+ out.pooled = {
323
+ naive_requests: zr + ir,
324
+ shown: zs + is,
325
+ overstatement_pct: zs + is === 0 ? null : pyRound(100 * ((zr + ir) / (zs + is) - 1), 1),
326
+ pooled_not_shown_pct: pct(100 * (zr - zs + i.episodes.not_shown), zr + ir),
327
+ zenodo_not_shown_pct: pct(100 * (zr - zs), zr),
328
+ zenodo_asked_again_pct: pct(100 * z.episodes.asked_again_within_300s_of_shown, zs),
329
+ idetrace_asked_again_pct: pct(100 * i.episodes.asked_again_within_300s_of_shown, is),
330
+ };
331
+ }
332
+ out.checks = checks;
333
+ return out;
334
+ }
335
+ // ── ASSISTments 2012-13 School Data with Affect ──────────────────────────
336
+ const STATES = ["FRUSTRATED", "CONFUSED", "CONCENTRATING", "BORED"];
337
+ const REQUIRED = ["user_id", "assignment_id", "assistment_id", "original", "hint_count", "bottom_hint", "first_action", "correct"];
338
+ /** Streams the CSV through Python's csv rules, calling `each` for every data row. Returns the input's SHA-256. */
339
+ async function streamCsv(path, each) {
340
+ const hash = createHash("sha256");
341
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
342
+ const parser = new PyCsv();
343
+ const seen = { header: null };
344
+ const take = (rows) => {
345
+ for (const row of rows) {
346
+ if (!seen.header) {
347
+ seen.header = row;
348
+ const missing = REQUIRED.filter((c) => !row.includes(c));
349
+ if (missing.length)
350
+ throw new Error(`the ASSISTments file is missing the columns: ${missing.join(", ")}`);
351
+ }
352
+ else
353
+ each(row, seen.header);
354
+ }
355
+ };
356
+ for await (const chunk of createReadStream(path, { highWaterMark: 1 << 22 })) {
357
+ hash.update(chunk);
358
+ take(parser.push(decoder.decode(chunk, { stream: true })));
359
+ }
360
+ take(parser.push(decoder.decode()));
361
+ take(parser.end());
362
+ if (!seen.header)
363
+ throw new Error("the ASSISTments file has no header row");
364
+ return hash.digest("hex");
365
+ }
366
+ export async function analyzeAssistments(path) {
367
+ const idx = (header) => {
368
+ const at = (name) => header.indexOf(name);
369
+ return { user: at("user_id"), assignment: at("assignment_id"), assistment: at("assistment_id"), original: at("original"), hint: at("hint_count"), bottom: at("bottom_hint"), first: at("first_action"), correct: at("correct"), affect: STATES.map((s) => at(`Average_confidence(${s})`)) };
370
+ };
371
+ const key = (row, c) => JSON.stringify([cell(row, c.user), cell(row, c.assignment), cell(row, c.assistment)]);
372
+ // pass 1: rows, affect completeness, and the scaffolding logs of each main problem
373
+ let rows = 0, rowsAff = 0;
374
+ const scaff = new Map();
375
+ const scaffHint = new Map();
376
+ let cols = null;
377
+ const inputSha = await streamCsv(path, (row, header) => {
378
+ const c = (cols ??= idx(header));
379
+ rows++;
380
+ if (c.affect.every((i) => cell(row, i)))
381
+ rowsAff++;
382
+ if (cell(row, c.original) !== "0")
383
+ return;
384
+ const k = key(row, c);
385
+ scaff.set(k, (scaff.get(k) ?? 0) + 1);
386
+ if (pyFloat(cell(row, c.hint) || "0") > 0)
387
+ scaffHint.set(k, (scaffHint.get(k) ?? 0) + 1);
388
+ });
389
+ // pass 2: the main problems, in file order
390
+ const out = {};
391
+ const topBy = {};
392
+ const users = new Set(), usersReq = new Set(), usersReqInclScaff = new Set();
393
+ let mains = 0, sysStepsWithScaffHint = 0, both = 0;
394
+ await streamCsv(path, (row) => {
395
+ const c = cols;
396
+ if (cell(row, c.original) === "0")
397
+ return;
398
+ mains++;
399
+ const k = key(row, c);
400
+ const a = c.affect.map((i) => cell(row, i));
401
+ let top = null;
402
+ if (a.every((v) => v)) {
403
+ const f = a.map((v) => pyFloat(v));
404
+ let best = 0;
405
+ for (let j = 1; j < 4; j++)
406
+ if (f[j] > f[best])
407
+ best = j;
408
+ top = STATES[best].toLowerCase();
409
+ }
410
+ const hc = pyIntOfFloat(cell(row, c.hint) || "0");
411
+ const bh = cell(row, c.bottom), fa = cell(row, c.first), corr = cell(row, c.correct);
412
+ const u = cell(row, c.user);
413
+ users.add(u);
414
+ const s = (scaff.get(k) ?? 0) > 0;
415
+ const askedHint = hc > 0, askedSteps = fa === "2", req = askedHint || askedSteps;
416
+ if (req)
417
+ usersReq.add(u);
418
+ if (req || (scaffHint.get(k) ?? 0) > 0)
419
+ usersReqInclScaff.add(u);
420
+ if (askedHint && askedSteps)
421
+ both++;
422
+ let cls;
423
+ if (askedHint && bh === "1")
424
+ cls = "requested; hints to the answer (bottom-out)";
425
+ else if (askedHint)
426
+ cls = "requested; hints short of the answer";
427
+ else if (askedSteps)
428
+ cls = "requested; asked for steps (scaffolding)";
429
+ else if (s && fa === "0" && (corr === "0" || corr === "0.0")) {
430
+ cls = "not requested; system gave steps after a wrong first answer";
431
+ if ((scaffHint.get(k) ?? 0) > 0)
432
+ sysStepsWithScaffHint++;
433
+ }
434
+ else if (s)
435
+ cls = "not requested; scaffolding present, trigger unclear";
436
+ else
437
+ cls = "not requested; no help recorded";
438
+ inc(out, cls);
439
+ if (top)
440
+ inc((topBy[req ? "requested" : "not requested"] ??= {}), top);
441
+ });
442
+ return {
443
+ input_sha256: inputSha,
444
+ main_problem_opportunities: mains,
445
+ scaffolding_logs: [...scaff.values()].reduce((x, y) => x + y, 0),
446
+ students: users.size,
447
+ students_who_ever_requested: usersReq.size,
448
+ episodes: out,
449
+ inferred_top_state_by_request: topBy,
450
+ checks: {
451
+ rows,
452
+ rows_with_all_four_affect_values: rowsAff,
453
+ scaffolding_logs_with_hints: [...scaffHint.values()].reduce((x, y) => x + y, 0),
454
+ main_problems_with_both_hint_and_first_action_steps: both,
455
+ system_steps_problems_with_a_hint_inside_scaffolding: sysStepsWithScaffHint,
456
+ students_who_ever_requested_including_hints_inside_scaffolding: usersReqInclScaff.size,
457
+ },
458
+ };
459
+ }
460
+ /**
461
+ * Help requests on main problems, counted by the problem's own skill label (the file's `skill` column).
462
+ * Counts only: no row, learner or answer leaves the file. The 15 most requested skills are kept.
463
+ */
464
+ export async function assistmentsBySkill(path) {
465
+ const by = new Map();
466
+ let col = null;
467
+ await streamCsv(path, (row, header) => {
468
+ col ??= { skill: header.indexOf("skill"), original: header.indexOf("original"), hint: header.indexOf("hint_count"), bottom: header.indexOf("bottom_hint"), first: header.indexOf("first_action") };
469
+ if (col.skill < 0 || cell(row, col.original) === "0")
470
+ return;
471
+ const name = cell(row, col.skill) || "(no skill label)";
472
+ const s = by.get(name) ?? { skill: name, main_problems: 0, requested: 0, hints_to_the_answer: 0, asked_for_steps: 0 };
473
+ const hints = pyIntOfFloat(cell(row, col.hint) || "0") > 0, steps = cell(row, col.first) === "2";
474
+ s.main_problems++;
475
+ if (hints || steps)
476
+ s.requested++;
477
+ if (hints && cell(row, col.bottom) === "1")
478
+ s.hints_to_the_answer++;
479
+ if (steps)
480
+ s.asked_for_steps++;
481
+ by.set(name, s);
482
+ });
483
+ if (col.skill < 0)
484
+ return { skills: [], skills_in_file: 0, note: "The file has no skill column, so requests can't be split by skill." };
485
+ const unlabeled = (x) => (x.skill === "(no skill label)" ? 1 : 0);
486
+ const all = [...by.values()].sort((a, b) => unlabeled(a) - unlabeled(b) || b.requested - a.requested || b.main_problems - a.main_problems || a.skill.localeCompare(b.skill));
487
+ return { skills: all.slice(0, 15), skills_in_file: all.length, note: "Counts only, on main problems. A skill is the problem's own label in the file; asking for help on it isn't evidence of a misconception." };
488
+ }
package/dist/files.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Every local tool reads the user's own files through here: an absolute path (or ~/...) to a regular
3
+ * file, within a size limit. Nothing is written, and nothing read here leaves the machine.
4
+ */
5
+ import { readFileSync, statSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { isAbsolute, join } from "node:path";
8
+ /** A reason a tool will not do what was asked, written for the person asking. */
9
+ export class Refusal extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "Refusal";
13
+ }
14
+ }
15
+ export function localFile(input, label, maxBytes) {
16
+ if (typeof input !== "string" || !input || input.includes("\u0000"))
17
+ throw new Refusal(`${label} needs a file path.`);
18
+ const path = input.startsWith("~/") ? join(homedir(), input.slice(2)) : input;
19
+ if (!isAbsolute(path))
20
+ throw new Refusal(`Give the full path to the file (for example /Users/you/data/export.csv), not "${input}".`);
21
+ let stat;
22
+ try {
23
+ stat = statSync(path);
24
+ }
25
+ catch {
26
+ throw new Refusal(`There is no file at ${path}.`);
27
+ }
28
+ if (!stat.isFile())
29
+ throw new Refusal(`${path} is a folder or device, not a regular file.`);
30
+ if (stat.size > maxBytes)
31
+ throw new Refusal(`${path} is ${stat.size} bytes; this tool reads files up to ${maxBytes} bytes.`);
32
+ return { path, bytes: stat.size };
33
+ }
34
+ export function readLocalBytes(input, label, maxBytes) {
35
+ return readFileSync(localFile(input, label, maxBytes).path);
36
+ }
37
+ export function readLocalText(input, label, maxBytes) {
38
+ return readLocalBytes(input, label, maxBytes).toString("utf8");
39
+ }
@@ -0,0 +1,14 @@
1
+ /** Where this package's files are, and what it calls itself. */
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /** The package root: src/ when tested, dist/ when installed, both one level down. */
6
+ export const ROOT = fileURLToPath(new URL("..", import.meta.url));
7
+ export const NAME = "learnlog-mcp";
8
+ export const VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
9
+ export function readJsonFile(relative) {
10
+ return JSON.parse(readFileSync(join(ROOT, relative), "utf8"));
11
+ }
12
+ /** The qlm-measure commit the vendored transformer, verifier and published counts were copied from. */
13
+ export const VENDORED_COMMIT = readJsonFile("vendor/PROVENANCE.json").commit;
14
+ export const VENDORED_FROM = `qlm-measure@${VENDORED_COMMIT.slice(0, 7)}`;