learn-loops 0.1.0 → 0.4.1
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 +64 -0
- package/dist/cli.js +138 -9
- package/dist/diagram.js +49 -0
- package/dist/figures.js +200 -0
- package/dist/flows/learn.js +115 -14
- package/dist/flows/onboard.js +14 -1
- package/dist/flows/quiz.js +104 -27
- package/dist/flows/think.js +120 -0
- package/dist/llm.js +15 -4
- package/dist/markdown.js +62 -11
- package/dist/math.js +307 -0
- package/dist/prompts.js +86 -10
- package/dist/stats.js +48 -0
- package/dist/store.js +8 -0
- package/dist/termimg.js +27 -0
- package/dist/ui.js +93 -7
- package/package.json +8 -3
package/dist/flows/learn.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { askJson, askText } from "../llm.js";
|
|
4
|
+
import { PERSONAS } from "../prompts.js";
|
|
4
5
|
import { bigPicturePrompt, deepLessonPrompt, mapPrompt, outlinePrompt, reexplainPrompt, surveyLessonPrompt } from "../prompts.js";
|
|
5
|
-
import { lessonMarkdown, mapMarkdown, writeIndex } from "../markdown.js";
|
|
6
|
-
import {
|
|
6
|
+
import { clean, lessonMarkdown, materializeFigures, mapMarkdown, mistakesMarkdown, nodeId, writeIndex } from "../markdown.js";
|
|
7
|
+
import { renderDiagram } from "../diagram.js";
|
|
8
|
+
import { addMistake, createTopic, listCategories, saveTopic, slugify, writeText } from "../store.js";
|
|
9
|
+
import { recordActivity } from "../stats.js";
|
|
7
10
|
import { addCards, isDue } from "../srs.js";
|
|
8
|
-
import { bar, must, renderMd, t, withSpinner } from "../ui.js";
|
|
11
|
+
import { bar, m, must, renderMd, t, withSpinner } from "../ui.js";
|
|
9
12
|
import { reviewCards, runChecks, teachBack } from "./quiz.js";
|
|
13
|
+
import { applicationChallenge, diagnose, predictThenReveal, socraticWarmup, thinkDeeper } from "./think.js";
|
|
10
14
|
const str = (v, name) => {
|
|
11
15
|
if (typeof v !== "string" || !v.trim())
|
|
12
16
|
throw new Error(`missing string "${name}"`);
|
|
@@ -24,7 +28,7 @@ function validateMap(x) {
|
|
|
24
28
|
}));
|
|
25
29
|
return { overview: String(x.overview ?? ""), areas };
|
|
26
30
|
}
|
|
27
|
-
function validateLesson(x) {
|
|
31
|
+
export function validateLesson(x) {
|
|
28
32
|
const checks = (Array.isArray(x.checks) ? x.checks : []).map((c) => {
|
|
29
33
|
if (!Array.isArray(c.options) || c.options.length < 2 || !Number.isInteger(c.answer) || c.answer < 0 || c.answer >= c.options.length)
|
|
30
34
|
throw new Error("bad check options/answer");
|
|
@@ -37,6 +41,9 @@ function validateLesson(x) {
|
|
|
37
41
|
teachBack: str(x.teachBack, "teachBack"),
|
|
38
42
|
keyPoints: (Array.isArray(x.keyPoints) ? x.keyPoints : []).map(String),
|
|
39
43
|
cards: (Array.isArray(x.cards) ? x.cards : []).filter((c) => c?.front && c?.back).map((c) => ({ front: String(c.front), back: String(c.back) })),
|
|
44
|
+
prediction: x.prediction?.scenario && x.prediction?.question && x.prediction?.outcome ? { scenario: String(x.prediction.scenario), question: String(x.prediction.question), outcome: String(x.prediction.outcome) } : undefined,
|
|
45
|
+
deeper: x.deeper?.question ? { question: String(x.deeper.question), considerations: (Array.isArray(x.deeper.considerations) ? x.deeper.considerations : []).map(String) } : undefined,
|
|
46
|
+
socratic: Array.isArray(x.socratic) ? x.socratic.filter((q) => q?.q).map((q) => ({ q: String(q.q), hint: String(q.hint ?? ""), idea: String(q.idea ?? "") })) : undefined,
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
49
|
const validateOutline = (x) => {
|
|
@@ -58,7 +65,12 @@ export async function newTopic(profile, title) {
|
|
|
58
65
|
message: `Brain dump: what do you already know (or think you know) about ${name}?`,
|
|
59
66
|
placeholder: "anything — even guesses. Blank if nothing. This sets the starting point.",
|
|
60
67
|
}));
|
|
68
|
+
const useCase = must(await p.text({
|
|
69
|
+
message: `Where do you hope to use ${name}? (a project, a job, a problem you have)`,
|
|
70
|
+
placeholder: "optional — examples will be drawn from this",
|
|
71
|
+
}));
|
|
61
72
|
const topic = await createTopic(profile, name.trim(), category, prior.trim());
|
|
73
|
+
topic.state.useCase = useCase.trim() || undefined;
|
|
62
74
|
await buildMap(profile, topic);
|
|
63
75
|
return topic;
|
|
64
76
|
}
|
|
@@ -81,6 +93,13 @@ export function printMap(topic) {
|
|
|
81
93
|
a.concepts.forEach((c, j) => lines.push(`${t.dim(last ? " " : "│ ")} ${t.dim(j === a.concepts.length - 1 ? "└─" : "├─")} ${c.name}`));
|
|
82
94
|
});
|
|
83
95
|
p.note(lines.join("\n"), `${topic.state.title} — the map`);
|
|
96
|
+
const edges = map.areas.flatMap((a) => a.prereqs.filter((q) => map.areas.some((x) => x.id === q)).map((q) => ` ${nodeId(q)} --> ${nodeId(a.id)}`));
|
|
97
|
+
if (edges.length) {
|
|
98
|
+
const flow = ["flowchart LR", ...map.areas.map((a) => ` ${nodeId(a.id)}["${clean(a.title)}"]`), ...edges].join("\n");
|
|
99
|
+
const art = renderDiagram(flow);
|
|
100
|
+
if (art)
|
|
101
|
+
p.note(art, "Suggested order (arrow = learn first)");
|
|
102
|
+
}
|
|
84
103
|
p.log.info(t.dim(`Saved with mermaid diagrams: ${join(topic.dir, "map.md")}`));
|
|
85
104
|
}
|
|
86
105
|
/** Warm-up retrieval of older material before new learning (spacing + interleaving). */
|
|
@@ -96,23 +115,30 @@ async function warmUp(topic) {
|
|
|
96
115
|
],
|
|
97
116
|
}));
|
|
98
117
|
if (go)
|
|
99
|
-
await reviewCards([topic], 5);
|
|
118
|
+
await reviewCards([topic], 5, "due");
|
|
100
119
|
}
|
|
101
120
|
async function teach(ctx) {
|
|
102
121
|
const { profile, topic } = ctx;
|
|
103
122
|
const lesson = await withSpinner(`Preparing "${ctx.title}"...`, ctx.load);
|
|
104
123
|
p.log.step(t.bold(t.accent(ctx.title)));
|
|
124
|
+
const extras = {};
|
|
125
|
+
// 0. Socratic mode: work toward the idea through leading questions first.
|
|
126
|
+
const socratic = ctx.deep && profile.socratic && lesson.socratic?.length ? lesson.socratic : undefined;
|
|
127
|
+
if (socratic)
|
|
128
|
+
extras.socratic = await socraticWarmup(socratic);
|
|
105
129
|
// 1. Pretest: guessing before reading primes attention even when wrong.
|
|
106
|
-
if (lesson.primer) {
|
|
107
|
-
must(await p.text({ message: `${t.pink("Guess first")} — ${lesson.primer}`, placeholder: "a rough guess is fine (enter to skip)" }));
|
|
130
|
+
if (lesson.primer && !socratic) {
|
|
131
|
+
must(await p.text({ message: `${t.pink("Guess first")} — ${m(lesson.primer)}`, placeholder: "a rough guess is fine (enter to skip)" }));
|
|
108
132
|
}
|
|
109
133
|
// 2. The chunk, with an escape hatch.
|
|
134
|
+
if (socratic)
|
|
135
|
+
p.log.message(t.dim("Here's how the pieces fit together:"));
|
|
110
136
|
console.log(renderMd(lesson.markdown) + "\n");
|
|
111
137
|
while (true) {
|
|
112
138
|
const next = must(await p.select({
|
|
113
139
|
message: "Does it make sense?",
|
|
114
140
|
options: [
|
|
115
|
-
{ value: "go", label: "Yes —
|
|
141
|
+
{ value: "go", label: "Yes — continue" },
|
|
116
142
|
{ value: "again", label: "Not really — explain it differently" },
|
|
117
143
|
],
|
|
118
144
|
}));
|
|
@@ -122,13 +148,66 @@ async function teach(ctx) {
|
|
|
122
148
|
console.log(renderMd(alt) + "\n");
|
|
123
149
|
lesson.markdown += `\n\n---\n\n### Another angle\n\n${alt}`;
|
|
124
150
|
}
|
|
125
|
-
// 3.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
// 3. Predict-then-reveal.
|
|
152
|
+
if (ctx.deep && lesson.prediction)
|
|
153
|
+
extras.prediction = await predictThenReveal(lesson.prediction);
|
|
154
|
+
// 4. Retrieval practice with confidence.
|
|
155
|
+
const result = await runChecks(lesson.checks);
|
|
156
|
+
extras.missed = result.missed.map((m) => m.q);
|
|
157
|
+
for (const m of result.missed) {
|
|
158
|
+
const over = result.overconfident.includes(m);
|
|
159
|
+
addMistake(topic, { lesson: ctx.title, kind: over ? "overconfident" : "quiz", what: m.q, correction: `${m.options[m.answer]} — ${m.explain}` });
|
|
160
|
+
}
|
|
161
|
+
// 5. Teach-back, to a persona on deep lessons.
|
|
162
|
+
const persona = ctx.deep ? PERSONAS[Math.floor(Math.random() * PERSONAS.length)] : undefined;
|
|
163
|
+
extras.attempts = await teachBack(profile, ctx.title, lesson.teachBack, lesson.keyPoints, persona);
|
|
164
|
+
const last = extras.attempts.at(-1)?.grade;
|
|
165
|
+
if (last && last.score <= 1) {
|
|
166
|
+
for (const g of last.misconceptions.length ? last.misconceptions : last.missing.slice(0, 2)) {
|
|
167
|
+
addMistake(topic, { lesson: ctx.title, kind: "explanation", what: g, correction: last.feedback });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 6. Stuck? Diagnose the mental model instead of repeating the lesson.
|
|
171
|
+
if (result.missed.length >= 2 || (last && last.score <= 1)) {
|
|
172
|
+
const go = must(await p.select({
|
|
173
|
+
message: "This one's tripping you up. Want me to find out why?",
|
|
174
|
+
options: [
|
|
175
|
+
{ value: true, label: "Yes — diagnose it", hint: "2 quick questions" },
|
|
176
|
+
{ value: false, label: "Skip" },
|
|
177
|
+
],
|
|
178
|
+
}));
|
|
179
|
+
if (go) {
|
|
180
|
+
const m = await diagnose(profile, ctx.title, result.missed, last?.feedback ?? "");
|
|
181
|
+
if (m)
|
|
182
|
+
addMistake(topic, m);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 7. Think deeper: trade-offs, failure modes, second-order effects.
|
|
186
|
+
if (ctx.deep) {
|
|
187
|
+
const rounds = await thinkDeeper(profile, topic.state, ctx.title, lesson.deeper);
|
|
188
|
+
if (rounds.length)
|
|
189
|
+
extras.deeper = rounds;
|
|
190
|
+
// 8. Personal connection.
|
|
191
|
+
const conn = must(await p.text({ message: `${t.pink("Where could you use this?")}`, placeholder: "a project, a decision, something you've seen (blank to skip)" })).trim();
|
|
192
|
+
if (conn) {
|
|
193
|
+
extras.connection = conn;
|
|
194
|
+
topic.state.connections = [...(topic.state.connections ?? []), conn].slice(-20);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Cards: lesson cards plus everything missed; confident mistakes are flagged to resurface first.
|
|
198
|
+
const cards = [...lesson.cards, ...result.missed.map((m) => ({ front: m.q, back: `${m.options[m.answer]} — ${m.explain}` }))];
|
|
129
199
|
const added = addCards(topic.state.cards, cards, ctx.title);
|
|
130
|
-
|
|
200
|
+
for (const m of result.overconfident) {
|
|
201
|
+
const c = topic.state.cards.find((x) => x.front.trim().toLowerCase() === m.q.trim().toLowerCase());
|
|
202
|
+
if (c)
|
|
203
|
+
c.flag = "overconfident";
|
|
204
|
+
}
|
|
205
|
+
await writeText(join(topic.dir, ctx.file), await materializeFigures(lessonMarkdown(ctx.title, lesson, extras), topic.dir, ctx.file));
|
|
206
|
+
await writeText(join(topic.dir, "mistakes.md"), mistakesMarkdown(topic));
|
|
131
207
|
p.log.success(`${added} flashcard${added === 1 ? "" : "s"} added to your review queue ${t.dim("→ " + ctx.file)}`);
|
|
208
|
+
const streak = await recordActivity();
|
|
209
|
+
if (streak.message)
|
|
210
|
+
p.log.success(t.warn("🔥 ") + t.bold(streak.message));
|
|
132
211
|
}
|
|
133
212
|
const overTime = (start, profile) => (Date.now() - start) / 60_000 >= profile.minutesPerSession;
|
|
134
213
|
export async function continueTopic(profile, topic) {
|
|
@@ -160,6 +239,7 @@ export async function continueTopic(profile, topic) {
|
|
|
160
239
|
profile,
|
|
161
240
|
topic,
|
|
162
241
|
title: next.title,
|
|
242
|
+
deep: false,
|
|
163
243
|
file: `survey/${next.id}.md`,
|
|
164
244
|
load: () => askJson(profile, surveyLessonPrompt(profile, s, next), validateLesson),
|
|
165
245
|
});
|
|
@@ -175,6 +255,10 @@ export async function continueTopic(profile, topic) {
|
|
|
175
255
|
if (!area)
|
|
176
256
|
return;
|
|
177
257
|
const pr = s.progress[area.id];
|
|
258
|
+
if (pr.outline && pr.done.length >= pr.outline.length) {
|
|
259
|
+
p.log.info(t.dim(`${area.title} is already complete — pick another.`));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
178
262
|
if (!pr.outline) {
|
|
179
263
|
pr.outline = await withSpinner(`Planning your deep dive into ${area.title}...`, () => askJson(profile, outlinePrompt(profile, s, area), validateOutline));
|
|
180
264
|
await saveTopic(topic);
|
|
@@ -189,6 +273,7 @@ export async function continueTopic(profile, topic) {
|
|
|
189
273
|
profile,
|
|
190
274
|
topic,
|
|
191
275
|
title: l.title,
|
|
276
|
+
deep: true,
|
|
192
277
|
file: `deep/${area.id}/${String(i + 1).padStart(2, "0")}-${l.id}.md`,
|
|
193
278
|
load: () => askJson(profile, deepLessonPrompt(profile, s, area, l, doneTitles), validateLesson),
|
|
194
279
|
});
|
|
@@ -199,6 +284,22 @@ export async function continueTopic(profile, topic) {
|
|
|
199
284
|
return;
|
|
200
285
|
}
|
|
201
286
|
p.log.success(t.ok(`You finished the deep dive on ${area.title}. 🎉`));
|
|
287
|
+
const take = must(await p.select({
|
|
288
|
+
message: "Test it on a real-world scenario with trade-offs?",
|
|
289
|
+
options: [
|
|
290
|
+
{ value: true, label: "Yes — application challenge" },
|
|
291
|
+
{ value: false, label: "Skip" },
|
|
292
|
+
],
|
|
293
|
+
}));
|
|
294
|
+
if (take) {
|
|
295
|
+
const res = await applicationChallenge(profile, s, area, pr.outline.map((l) => l.title));
|
|
296
|
+
if (res) {
|
|
297
|
+
await writeText(join(topic.dir, "deep", area.id, "challenge.md"), `# Challenge — ${area.title}\n\n${res.scenario}\n\n**Task:** ${res.task}\n\n## My answer\n\n${res.answer.split("\n").map((l) => "> " + l).join("\n")}\n\n## Feedback\n\n${res.critique.feedback}\n\n${res.critique.blindSpots.map((b) => `- Overlooked: ${b}`).join("\n")}\n`);
|
|
298
|
+
const r = await recordActivity();
|
|
299
|
+
if (r.message)
|
|
300
|
+
p.log.success(t.warn("🔥 ") + t.bold(r.message));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
202
303
|
const more = must(await p.select({ message: "Next?", options: [{ value: true, label: "Pick another area to go deep on" }, { value: false, label: "Stop here" }] }));
|
|
203
304
|
if (!more)
|
|
204
305
|
return;
|
|
@@ -224,7 +325,7 @@ async function pickArea(topic) {
|
|
|
224
325
|
p.log.success("You've gone deep on every area. Review your cards to keep it fresh with `loops review`.");
|
|
225
326
|
return null;
|
|
226
327
|
}
|
|
227
|
-
const started = open.find((a) => s.progress[a.id].done.length > 0);
|
|
328
|
+
const started = open.find((a) => s.progress[a.id].done.length > 0) ?? open[0];
|
|
228
329
|
const choice = must(await p.select({
|
|
229
330
|
message: "Which area do you want to go deep on?",
|
|
230
331
|
initialValue: started?.id,
|
package/dist/flows/onboard.js
CHANGED
|
@@ -98,13 +98,26 @@ export async function onboard(existing) {
|
|
|
98
98
|
{ value: "socratic", label: "Socratic — ask me questions" },
|
|
99
99
|
],
|
|
100
100
|
}));
|
|
101
|
+
const socratic = must(await p.select({
|
|
102
|
+
message: "How should new ideas be introduced?",
|
|
103
|
+
initialValue: d?.socratic ?? false,
|
|
104
|
+
options: [
|
|
105
|
+
{ value: false, label: "Explain, then quiz me", hint: "classic" },
|
|
106
|
+
{ value: true, label: "Socratic — ask me leading questions first", hint: "slower, sticks better" },
|
|
107
|
+
],
|
|
108
|
+
}));
|
|
109
|
+
const context = must(await p.text({
|
|
110
|
+
message: "What are you working on or hoping to use this for? (job, project, problem)",
|
|
111
|
+
initialValue: d?.context,
|
|
112
|
+
placeholder: "optional — examples will be drawn from this",
|
|
113
|
+
}));
|
|
101
114
|
const minutesPerSession = must(await p.select({
|
|
102
115
|
message: "How long is a typical study session?",
|
|
103
116
|
initialValue: d?.minutesPerSession ?? 25,
|
|
104
117
|
options: [10, 15, 25, 45, 60].map((m) => ({ value: m, label: `${m} min` })),
|
|
105
118
|
}));
|
|
106
119
|
const rootDir = must(await p.text({ message: "Where should your learning folders live?", initialValue: d?.rootDir ?? join(homedir(), "Learning").replace(homedir(), "~") }));
|
|
107
|
-
const profile = { name: name.trim(), provider, goal, level, minutesPerSession, styles, order, diagrams, challenge, tone, rootDir };
|
|
120
|
+
const profile = { name: name.trim(), provider, goal, level, minutesPerSession, styles, order, diagrams, challenge, tone, rootDir, socratic, context: context.trim() || undefined };
|
|
108
121
|
await saveProfile(profile);
|
|
109
122
|
p.outro(t.ok(`Profile saved. Nice to meet you, ${profile.name}!`));
|
|
110
123
|
return profile;
|
package/dist/flows/quiz.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { askJson } from "../llm.js";
|
|
3
|
-
import { gradeLabel, gradePrompt } from "../prompts.js";
|
|
3
|
+
import { gradeLabel, gradePrompt, personaPrompt } from "../prompts.js";
|
|
4
4
|
import { schedule, isDue } from "../srs.js";
|
|
5
|
-
import { must, t, withSpinner } from "../ui.js";
|
|
5
|
+
import { m, must, t, withSpinner } from "../ui.js";
|
|
6
6
|
import { saveTopic } from "../store.js";
|
|
7
7
|
export function validateGrade(x) {
|
|
8
8
|
const score = Number(x.score);
|
|
@@ -11,42 +11,93 @@ export function validateGrade(x) {
|
|
|
11
11
|
const arr = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
12
12
|
return { score: score, right: arr(x.right), missing: arr(x.missing), misconceptions: arr(x.misconceptions), feedback: String(x.feedback ?? ""), followUp: String(x.followUp ?? "") };
|
|
13
13
|
}
|
|
14
|
-
/** Multiple-choice checks
|
|
14
|
+
/** Multiple-choice checks with a confidence rating, so confident mistakes are caught and flagged. */
|
|
15
15
|
export async function runChecks(checks) {
|
|
16
|
-
const
|
|
16
|
+
const res = { missed: [], overconfident: [], correct: 0, total: checks.length };
|
|
17
|
+
let sure = 0;
|
|
18
|
+
let sureRight = 0;
|
|
17
19
|
for (const [i, c] of checks.entries()) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Models over-favour option A: shuffle what's shown, keep `c` untouched for logging.
|
|
21
|
+
const order = c.options.map((_, idx) => idx).sort(() => Math.random() - 0.5);
|
|
22
|
+
const shown = must(await p.select({
|
|
23
|
+
message: `${t.accent(`Q${i + 1}/${checks.length}`)} ${m(c.q)}`,
|
|
24
|
+
options: order.map((orig, pos) => ({ value: pos, label: m(c.options[orig]) })),
|
|
21
25
|
}));
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const picked = order[shown];
|
|
27
|
+
const confidence = must(await p.select({
|
|
28
|
+
message: "How sure are you?",
|
|
29
|
+
options: [
|
|
30
|
+
{ value: 1, label: "Guessing" },
|
|
31
|
+
{ value: 2, label: "Not sure" },
|
|
32
|
+
{ value: 3, label: "Pretty sure" },
|
|
33
|
+
{ value: 4, label: "Certain" },
|
|
34
|
+
],
|
|
35
|
+
}));
|
|
36
|
+
const right = picked === c.answer;
|
|
37
|
+
if (confidence >= 3) {
|
|
38
|
+
sure++;
|
|
39
|
+
if (right)
|
|
40
|
+
sureRight++;
|
|
41
|
+
}
|
|
42
|
+
if (right) {
|
|
43
|
+
res.correct++;
|
|
44
|
+
p.log.success(t.ok("Correct. ") + t.dim(m(c.explain)));
|
|
45
|
+
}
|
|
24
46
|
else {
|
|
25
|
-
missed.push(c);
|
|
26
|
-
|
|
47
|
+
res.missed.push(c);
|
|
48
|
+
if (confidence >= 3) {
|
|
49
|
+
res.overconfident.push(c);
|
|
50
|
+
p.log.error(t.bad("Wrong — and you were sure. ") + t.warn("Those are the most valuable mistakes to catch.") + `\n Answer: ${m(c.options[c.answer])}\n ${t.dim(m(c.explain))}`);
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
p.log.error(t.bad("Not quite. ") + `Answer: ${m(c.options[c.answer])}\n ${t.dim(m(c.explain))}`);
|
|
27
54
|
}
|
|
28
55
|
}
|
|
29
|
-
|
|
56
|
+
if (sure > 0 && checks.length > 1)
|
|
57
|
+
p.log.info(t.dim(`Calibration: right on ${sureRight} of ${sure} answers you were sure about.`));
|
|
58
|
+
return res;
|
|
30
59
|
}
|
|
31
60
|
export function showGrade(g) {
|
|
32
61
|
const color = g.score >= 3 ? t.ok : g.score === 2 ? t.cyan : g.score === 1 ? t.warn : t.bad;
|
|
33
62
|
const lines = [
|
|
34
63
|
`${color(`${"●".repeat(g.score)}${"○".repeat(3 - g.score)} ${gradeLabel(g)}`)}`,
|
|
35
64
|
"",
|
|
36
|
-
g.feedback,
|
|
37
|
-
...g.right.map((r) => `${t.ok("✓")} ${r}`),
|
|
38
|
-
...g.missing.map((r) => `${t.warn("△")} missing: ${r}`),
|
|
39
|
-
...g.misconceptions.map((r) => `${t.bad("✗")} ${r}`),
|
|
65
|
+
m(g.feedback),
|
|
66
|
+
...g.right.map((r) => `${t.ok("✓")} ${m(r)}`),
|
|
67
|
+
...g.missing.map((r) => `${t.warn("△")} missing: ${m(r)}`),
|
|
68
|
+
...g.misconceptions.map((r) => `${t.bad("✗")} ${m(r)}`),
|
|
40
69
|
];
|
|
41
70
|
p.note(lines.join("\n"), "Feedback");
|
|
42
71
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Feynman-style teach-back. With a persona, you explain to a character who then asks a follow-up
|
|
74
|
+
* question about your biggest gap; the whole exchange is graded. Without, you get one graded retry.
|
|
75
|
+
*/
|
|
76
|
+
export async function teachBack(profile, title, prompt, keyPoints, persona) {
|
|
77
|
+
if (persona) {
|
|
78
|
+
const first = must(await p.text({ message: `${t.pink("Explain it to " + persona.label)} — ${m(prompt)}`, placeholder: "in your own words (enter to submit, blank to skip)" })).trim();
|
|
79
|
+
if (!first)
|
|
80
|
+
return [];
|
|
81
|
+
let transcript = first;
|
|
82
|
+
try {
|
|
83
|
+
const turn = await withSpinner(`${persona.label[0].toUpperCase() + persona.label.slice(1)} is thinking...`, () => askJson(profile, personaPrompt(profile, persona, title, keyPoints, first), (x) => ({ reaction: String(x.reaction ?? ""), question: String(x.question ?? "") })));
|
|
84
|
+
p.log.message(`${t.pink(persona.label)}: ${m(turn.reaction)}\n${t.bold(m(turn.question))}`);
|
|
85
|
+
const second = must(await p.text({ message: "Your reply", placeholder: "answer their question (blank to skip)" })).trim();
|
|
86
|
+
if (second)
|
|
87
|
+
transcript += `\n\n[Follow-up: ${turn.question}]\n${second}`;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* persona is a bonus; fall through to grading the first answer */
|
|
91
|
+
}
|
|
92
|
+
const grade = await withSpinner("Reading your explanation...", () => askJson(profile, gradePrompt(profile, title, prompt, keyPoints, transcript), validateGrade));
|
|
93
|
+
showGrade(grade);
|
|
94
|
+
return [{ answer: transcript, grade }];
|
|
95
|
+
}
|
|
45
96
|
const attempts = [];
|
|
46
97
|
let ask = prompt;
|
|
47
98
|
for (let i = 0; i < 2; i++) {
|
|
48
99
|
const answer = must(await p.text({
|
|
49
|
-
message: `${t.pink("In your own words")} — ${ask}`,
|
|
100
|
+
message: `${t.pink("In your own words")} — ${m(ask)}`,
|
|
50
101
|
placeholder: "type your explanation (enter to submit, leave blank to skip)",
|
|
51
102
|
})).trim();
|
|
52
103
|
if (!answer)
|
|
@@ -69,14 +120,37 @@ export async function teachBack(profile, title, prompt, keyPoints) {
|
|
|
69
120
|
}
|
|
70
121
|
return attempts;
|
|
71
122
|
}
|
|
72
|
-
/**
|
|
73
|
-
export
|
|
74
|
-
const
|
|
123
|
+
/** Round-robin across sources so related cards aren't back-to-back (interleaving). Stable per source order. */
|
|
124
|
+
export function interleave(items) {
|
|
125
|
+
const groups = new Map();
|
|
126
|
+
for (const it of items)
|
|
127
|
+
groups.set(it.card.source, [...(groups.get(it.card.source) ?? []), it]);
|
|
128
|
+
const queues = [...groups.values()].sort(() => Math.random() - 0.5);
|
|
129
|
+
const out = [];
|
|
130
|
+
while (queues.some((q) => q.length))
|
|
131
|
+
for (const q of queues)
|
|
132
|
+
if (q.length)
|
|
133
|
+
out.push(q.shift());
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** Weak spots first: confident mistakes, lapses, then overdue. */
|
|
137
|
+
const weakness = (c) => (c.flag === "overconfident" ? 4 : 0) + c.lapses * 1.5 + (isDue(c) ? 2 : 0) + Math.random();
|
|
138
|
+
export function pickCards(topics, limit, mode) {
|
|
139
|
+
const all = topics.flatMap((tp) => tp.state.cards.map((card) => ({ tp, card })));
|
|
140
|
+
const pool = mode === "due" ? all.filter((e) => isDue(e.card)) : all;
|
|
141
|
+
const flagged = pool.filter((e) => e.card.flag === "overconfident").sort(() => Math.random() - 0.5);
|
|
142
|
+
const rest = pool.filter((e) => e.card.flag !== "overconfident").sort((a, b) => weakness(b.card) - weakness(a.card));
|
|
143
|
+
const chosen = [...flagged, ...rest].slice(0, limit);
|
|
144
|
+
return [...chosen.filter((e) => e.card.flag === "overconfident"), ...interleave(chosen.filter((e) => e.card.flag !== "overconfident"))];
|
|
145
|
+
}
|
|
146
|
+
/** Spaced-repetition review (due cards) or an interleaved mixed quiz (any cards, weakest first). */
|
|
147
|
+
export async function reviewCards(topics, limit = 20, mode = "due") {
|
|
148
|
+
const queue = pickCards(topics, limit, mode);
|
|
75
149
|
let n = 0;
|
|
76
|
-
for (const { tp, card } of
|
|
77
|
-
console.log(t.dim(`\n ${tp.state.title} · ${card.source}`));
|
|
78
|
-
must(await p.text({ message: `${t.accent("Recall")} ${card.front}`, placeholder: "type what you remember (enter to just think)" }));
|
|
79
|
-
p.log.info(`${t.bold("Answer:")} ${card.back}`);
|
|
150
|
+
for (const { tp, card } of queue) {
|
|
151
|
+
console.log(t.dim(`\n ${tp.state.title} · ${card.source}${card.flag ? " · " + t.warn("you were sure and wrong here before") : ""}`));
|
|
152
|
+
must(await p.text({ message: `${t.accent("Recall")} ${m(card.front)}`, placeholder: "type what you remember (enter to just think)" }));
|
|
153
|
+
p.log.info(`${t.bold("Answer:")} ${m(card.back)}`);
|
|
80
154
|
const rating = must(await p.select({
|
|
81
155
|
message: "How did that go?",
|
|
82
156
|
options: [
|
|
@@ -90,7 +164,10 @@ export async function reviewCards(topics, limit = 20) {
|
|
|
90
164
|
if (rating === "stop")
|
|
91
165
|
break;
|
|
92
166
|
const idx = tp.state.cards.findIndex((c) => c.id === card.id);
|
|
93
|
-
|
|
167
|
+
const next = schedule(card, rating);
|
|
168
|
+
if (card.flag && (rating === "good" || rating === "easy"))
|
|
169
|
+
delete next.flag; // earned its way out
|
|
170
|
+
tp.state.cards[idx] = next;
|
|
94
171
|
await saveTopic(tp);
|
|
95
172
|
n++;
|
|
96
173
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { askJson } from "../llm.js";
|
|
3
|
+
import { LENSES, challengePrompt, critiquePrompt, deeperPrompt, diagnoseQuestionsPrompt, diagnoseResultPrompt } from "../prompts.js";
|
|
4
|
+
import { m, must, renderMd, t, withSpinner } from "../ui.js";
|
|
5
|
+
import { runChecks } from "./quiz.js";
|
|
6
|
+
const arr = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
7
|
+
const validateCritique = (x) => ({ strong: arr(x.strong), blindSpots: arr(x.blindSpots), pushback: String(x.pushback ?? ""), feedback: String(x.feedback ?? "") });
|
|
8
|
+
function showCritique(c) {
|
|
9
|
+
p.note([m(c.feedback), "", ...c.strong.map((s) => `${t.ok("✓")} ${m(s)}`), ...c.blindSpots.map((s) => `${t.warn("△ overlooked:")} ${m(s)}`), ...(c.pushback ? ["", `${t.pink("Push back:")} ${m(c.pushback)}`] : [])].join("\n"), "Thinking check");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Trade-offs, failure modes, scale and second-order effects. Round one uses the lesson's own question;
|
|
13
|
+
* further rounds rotate through different lenses so the learner practises seeing a topic from many sides.
|
|
14
|
+
*/
|
|
15
|
+
export async function thinkDeeper(profile, s, title, seed, maxRounds = 3) {
|
|
16
|
+
const rounds = [];
|
|
17
|
+
let lensIdx = Math.floor(Math.random() * LENSES.length);
|
|
18
|
+
let current = seed ? { lens: "Think deeper", ...seed } : undefined;
|
|
19
|
+
for (let r = 0; r < maxRounds; r++) {
|
|
20
|
+
if (!current) {
|
|
21
|
+
const lens = LENSES[lensIdx % LENSES.length];
|
|
22
|
+
const q = await withSpinner(`Finding a new angle (${lens.label.toLowerCase()})...`, () => askJson(profile, deeperPrompt(profile, s, title, lens, rounds.map((x) => x.question)), (x) => ({ question: String(x.question), considerations: arr(x.considerations) })));
|
|
23
|
+
current = { lens: lens.label, ...q };
|
|
24
|
+
}
|
|
25
|
+
p.log.step(t.bold(t.accent(`Think deeper · ${current.lens}`)));
|
|
26
|
+
const answer = must(await p.text({ message: m(current.question), placeholder: "no single right answer: weigh both sides, name what could go wrong (blank to skip)" })).trim();
|
|
27
|
+
if (!answer)
|
|
28
|
+
break;
|
|
29
|
+
const c = await withSpinner("Weighing your reasoning...", () => askJson(profile, critiquePrompt(profile, title, current.question, current.considerations, answer), validateCritique));
|
|
30
|
+
showCritique(c);
|
|
31
|
+
rounds.push({ lens: current.lens, question: current.question, answer, feedback: c.feedback, blindSpots: c.blindSpots });
|
|
32
|
+
// Let them answer the pushback, which is the real second-order step.
|
|
33
|
+
if (c.pushback) {
|
|
34
|
+
const reply = must(await p.text({ message: `${t.pink("Your response to the push back?")}`, placeholder: "optional (blank to skip)" })).trim();
|
|
35
|
+
if (reply)
|
|
36
|
+
rounds[rounds.length - 1].answer += `\n\n[Push back: ${c.pushback}]\n${reply}`;
|
|
37
|
+
}
|
|
38
|
+
if (r === maxRounds - 1)
|
|
39
|
+
break;
|
|
40
|
+
lensIdx++;
|
|
41
|
+
const next = LENSES[lensIdx % LENSES.length];
|
|
42
|
+
const more = must(await p.select({
|
|
43
|
+
message: "Keep stretching your thinking?",
|
|
44
|
+
options: [
|
|
45
|
+
{ value: true, label: `Another angle: ${next.label}` },
|
|
46
|
+
{ value: false, label: "That's enough" },
|
|
47
|
+
],
|
|
48
|
+
}));
|
|
49
|
+
if (!more)
|
|
50
|
+
break;
|
|
51
|
+
current = undefined;
|
|
52
|
+
}
|
|
53
|
+
return rounds;
|
|
54
|
+
}
|
|
55
|
+
/** End-of-area scenario with no single right answer: apply what you learned and weigh trade-offs. */
|
|
56
|
+
export async function applicationChallenge(profile, s, area, covered) {
|
|
57
|
+
const ch = await withSpinner("Designing a real-world challenge...", () => askJson(profile, challengePrompt(profile, s, area, covered), (x) => ({ scenario: String(x.scenario), task: String(x.task), considerations: arr(x.considerations) })));
|
|
58
|
+
p.note(`${m(ch.scenario)}\n\n${t.bold("Your task:")} ${m(ch.task)}`, `Challenge · ${area.title}`);
|
|
59
|
+
const answer = must(await p.text({ message: "What would you do, and why? What could go wrong?", placeholder: "reason it through (blank to skip)" })).trim();
|
|
60
|
+
if (!answer)
|
|
61
|
+
return null;
|
|
62
|
+
const c = await withSpinner("Reviewing your reasoning...", () => askJson(profile, critiquePrompt(profile, area.title, ch.task, ch.considerations, answer), validateCritique));
|
|
63
|
+
showCritique(c);
|
|
64
|
+
return { ...ch, answer, critique: c };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* When a learner keeps missing, find the mental model that's wrong instead of repeating the lesson.
|
|
68
|
+
* Returns the logged mistake (with the learner's corrected idea in their own words).
|
|
69
|
+
*/
|
|
70
|
+
export async function diagnose(profile, title, missed, explanationFeedback) {
|
|
71
|
+
const questions = await withSpinner("Working out where the confusion is...", () => askJson(profile, diagnoseQuestionsPrompt(profile, title, missed.map((m) => m.q), explanationFeedback), (x) => arr(x.questions).slice(0, 2)));
|
|
72
|
+
const qa = [];
|
|
73
|
+
for (const q of questions) {
|
|
74
|
+
const a = must(await p.text({ message: `${t.pink("Quick question")} — ${m(q)}`, placeholder: "just say what you picture or believe (blank to skip)" })).trim();
|
|
75
|
+
qa.push({ q, a });
|
|
76
|
+
}
|
|
77
|
+
const res = await withSpinner("Finding the misconception...", () => askJson(profile, diagnoseResultPrompt(profile, title, qa, missed.map((m) => m.q)), (x) => {
|
|
78
|
+
const rt = x.retest;
|
|
79
|
+
if (!rt || !Array.isArray(rt.options) || !Number.isInteger(rt.answer) || rt.answer < 0 || rt.answer >= rt.options.length)
|
|
80
|
+
throw new Error("bad retest");
|
|
81
|
+
return {
|
|
82
|
+
misconception: String(x.misconception),
|
|
83
|
+
reteach: String(x.reteach),
|
|
84
|
+
retest: { q: String(rt.q), options: rt.options.map(String), answer: rt.answer, explain: String(rt.explain ?? "") },
|
|
85
|
+
};
|
|
86
|
+
}));
|
|
87
|
+
p.note(`${t.warn("Likely snag:")} ${m(res.misconception)}`, "Diagnosis");
|
|
88
|
+
console.log(renderMd(res.reteach) + "\n");
|
|
89
|
+
await runChecks([res.retest]);
|
|
90
|
+
const inMyWords = must(await p.text({ message: `${t.pink("Say the corrected idea in your own words")}`, placeholder: "one or two sentences (blank to skip)" })).trim();
|
|
91
|
+
return {
|
|
92
|
+
lesson: title,
|
|
93
|
+
kind: "diagnosis",
|
|
94
|
+
what: res.misconception,
|
|
95
|
+
correction: res.reteach.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("`")) ?? res.misconception,
|
|
96
|
+
inMyWords: inMyWords || undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Socratic opener: leading questions the learner works through before the explanation. */
|
|
100
|
+
export async function socraticWarmup(items) {
|
|
101
|
+
p.log.message(t.dim("Socratic mode: work toward the idea yourself first. Answers needn't be right."));
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const [i, item] of items.entries()) {
|
|
104
|
+
let answer = must(await p.text({ message: `${t.pink(`${i + 1}/${items.length}`)} ${m(item.q)}`, placeholder: "think out loud (type ? for a hint, blank to skip)" })).trim();
|
|
105
|
+
if (answer === "?") {
|
|
106
|
+
p.log.info(`${t.warn("Hint:")} ${m(item.hint)}`);
|
|
107
|
+
answer = must(await p.text({ message: item.q, placeholder: "try again with the hint (blank to skip)" })).trim();
|
|
108
|
+
}
|
|
109
|
+
p.log.message(`${t.accent("→")} ${m(item.idea)}`);
|
|
110
|
+
out.push({ q: item.q, answer });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/** Predict-then-reveal: a wrong guess followed by the truth is memorable. */
|
|
115
|
+
export async function predictThenReveal(pred) {
|
|
116
|
+
p.note(m(pred.scenario), t.pink("Predict"));
|
|
117
|
+
const guess = must(await p.text({ message: m(pred.question), placeholder: "commit to a guess before the reveal (blank to skip)" })).trim();
|
|
118
|
+
p.log.message(`${t.accent("Reveal:")} ${m(pred.outcome)}`);
|
|
119
|
+
return guess;
|
|
120
|
+
}
|
package/dist/llm.js
CHANGED
|
@@ -47,13 +47,24 @@ export async function complete(provider, prompt, timeoutMs = 240_000) {
|
|
|
47
47
|
await rm(dir, { recursive: true, force: true });
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Models often forget to double LaTeX backslashes inside JSON strings. `\theta` would silently parse as a
|
|
52
|
+
* tab + "heta", `\nabla` as newline + "abla", `\frac` as form feed + "rac". Double the backslash of any
|
|
53
|
+
* known command that collides with a JSON escape, and any lone backslash that isn't a valid escape at all.
|
|
54
|
+
*/
|
|
55
|
+
const COLLIDING = "theta|tau|times|text|textbf|textit|textrm|tan|tanh|tilde|to|top|triangle|therefore|tfrac|thinspace|frac|forall|flat|beta|bar|begin|boldsymbol|bot|binom|big|bigg|Big|Bigg|bmod|bullet|bigcup|bigcap|bigoplus|bf|rho|right|rightarrow|rangle|rbrace|rceil|rfloor|rm|nabla|neq|nu|ne|not|notin|newcommand|ni|neg|nleq|ngeq|nmid|nearrow";
|
|
56
|
+
export function repairLatexEscapes(json) {
|
|
57
|
+
const re = new RegExp(`\\\\\\\\|\\\\(?:(?=(?:${COLLIDING})(?![a-zA-Z]))|u(?![0-9a-fA-F]{4})|(?![\\\\"/bfnrtu]))`, "g");
|
|
58
|
+
return json.replace(re, (m) => (m === "\\\\" ? m : "\\\\" + m.slice(1)));
|
|
59
|
+
}
|
|
50
60
|
export function extractJson(text) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
61
|
+
// Slice between the outermost braces; an outer ```json fence falls outside them. Don't strip
|
|
62
|
+
// backticks globally: lesson markdown inside the JSON contains ```mermaid fences.
|
|
63
|
+
const start = text.indexOf("{");
|
|
64
|
+
const end = text.lastIndexOf("}");
|
|
54
65
|
if (start === -1 || end <= start)
|
|
55
66
|
throw new Error("no JSON object in model output");
|
|
56
|
-
return JSON.parse(
|
|
67
|
+
return JSON.parse(repairLatexEscapes(text.slice(start, end + 1)));
|
|
57
68
|
}
|
|
58
69
|
/** Ask for JSON, validate it, and retry once telling the model what was wrong. */
|
|
59
70
|
export async function askJson(profile, prompt, validate) {
|