dsh-aha-tutor 1.0.0 → 1.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.
@@ -667,6 +667,18 @@ function normalizeOp(raw, index) {
667
667
  default: return null;
668
668
  }
669
669
  }
670
+ function syncOps(ops, rawAts) {
671
+ if (!rawAts.some((value) => value !== null)) return;
672
+ let current = 0;
673
+ ops.forEach((op, index) => {
674
+ current = Math.max(current, rawAts[index] ?? current);
675
+ op.at = current;
676
+ });
677
+ }
678
+ function sentenceIndex(value) {
679
+ const number = Number(value);
680
+ return value !== null && value !== "" && Number.isInteger(number) && number >= 0 ? Math.min(19, number) : null;
681
+ }
670
682
  function normalizeBoard(raw, { conceptIds = [] } = {}) {
671
683
  const sourceSteps = Array.isArray(raw) ? raw : Array.isArray(raw?.steps) ? raw.steps : [];
672
684
  const steps = [];
@@ -675,6 +687,7 @@ function normalizeBoard(raw, { conceptIds = [] } = {}) {
675
687
  for (const rawStep of sourceSteps.slice(0, BOARD_LIMITS.steps)) {
676
688
  const say = text(rawStep?.say ?? rawStep?.narration ?? rawStep?.speech, BOARD_LIMITS.say);
677
689
  const ops = [];
690
+ const ats = [];
678
691
  for (const rawOp of (Array.isArray(rawStep?.ops ?? rawStep?.board) ? rawStep.ops ?? rawStep.board : []).slice(0, BOARD_LIMITS.opsPerStep)) {
679
692
  const op = normalizeOp(rawOp, opCounter);
680
693
  if (!op) continue;
@@ -682,13 +695,17 @@ function normalizeBoard(raw, { conceptIds = [] } = {}) {
682
695
  op.id = `o${++opCounter}`;
683
696
  if (originalId) idMap.set(originalId, op.id);
684
697
  ops.push(op);
698
+ ats.push(sentenceIndex(rawOp?.at));
685
699
  }
700
+ syncOps(ops, ats);
686
701
  const check = rawStep?.check ? normalizeItem(rawStep.check, steps.length, conceptIds) : null;
687
702
  if (!say && !ops.length && !check) continue;
703
+ const goal = sentenceIndex(rawStep?.goal);
688
704
  steps.push({
689
705
  id: `s${steps.length + 1}`,
690
706
  say,
691
707
  ops,
708
+ ...goal !== null && goal < 12 ? { goal } : {},
692
709
  ...check && check.kind !== "order" ? { check: {
693
710
  ...check,
694
711
  id: `k${steps.length + 1}`
@@ -1014,10 +1031,53 @@ function selectPassages(chunks, query, { maxChars = 12e3, limit = 12 } = {}) {
1014
1031
  }
1015
1032
  return result;
1016
1033
  }
1034
+ function spreadPassages(chunks, { maxChars = 12e3, samples = 16 } = {}) {
1035
+ if (!chunks?.length) return [];
1036
+ if (chunks.reduce((sum, chunk) => sum + chunk.text.length, 0) <= maxChars) return chunks.slice();
1037
+ const count = Math.min(chunks.length, Math.max(2, samples));
1038
+ const share = Math.max(200, Math.floor(maxChars / count));
1039
+ const picked = [];
1040
+ for (let index = 0; index < count; index += 1) {
1041
+ const at = Math.round(index * (chunks.length - 1) / (count - 1));
1042
+ if (picked.at(-1) !== at) picked.push(at);
1043
+ }
1044
+ return picked.map((at) => {
1045
+ const chunk = chunks[at];
1046
+ return chunk.text.length > share ? {
1047
+ ...chunk,
1048
+ text: `${chunk.text.slice(0, share)}…`
1049
+ } : chunk;
1050
+ });
1051
+ }
1052
+ function documentParts(chunks, { partChars = 2e4, maxParts = 24, maxPartChars = 4e4 } = {}) {
1053
+ if (!chunks?.length) return [];
1054
+ const total = Math.max(1, chunks.reduce((sum, chunk) => sum + chunk.text.length, 0));
1055
+ const count = Math.min(maxParts, Math.max(1, Math.ceil(total / partChars)));
1056
+ const parts = Array.from({ length: count }, () => []);
1057
+ let seen = 0;
1058
+ for (const chunk of chunks) {
1059
+ parts[Math.min(count - 1, Math.floor(seen / total * count))].push(chunk);
1060
+ seen += chunk.text.length;
1061
+ }
1062
+ return parts.filter((part) => part.length).map((part) => {
1063
+ return part.reduce((sum, chunk) => sum + chunk.text.length, 0) > maxPartChars ? spreadPassages(part, {
1064
+ maxChars: maxPartChars,
1065
+ samples: 24
1066
+ }) : part;
1067
+ });
1068
+ }
1069
+ function locationOf(passage) {
1070
+ return passage?.page ? `p.${passage.page}` : passage?.slide ? `slide ${passage.slide}` : passage?.section ?? "";
1071
+ }
1072
+ function passageRange(passages) {
1073
+ const first = locationOf(passages?.[0]);
1074
+ const last = locationOf(passages?.at(-1));
1075
+ return first && last && first !== last ? `${first}–${last}` : first || last;
1076
+ }
1017
1077
  function formatPassages(passages, sourceTitle) {
1018
1078
  return passages.map((passage) => {
1019
- const where = passage.page ? `p.${passage.page}` : passage.slide ? `slide ${passage.slide}` : passage.section ?? "";
1020
- return `<<${sourceTitle ?? passage.source ?? "source"}${where ? ` · ${where}` : ""}>>\n${passage.text}`;
1079
+ const where = locationOf(passage);
1080
+ return `<<${[sourceTitle ?? passage.source ?? "source", where].filter(Boolean).join(" · ") || "excerpt"}>>\n${passage.text}`;
1021
1081
  }).join("\n\n");
1022
1082
  }
1023
1083
  //#endregion
@@ -1284,15 +1344,19 @@ var TutorError = class extends Error {
1284
1344
  }
1285
1345
  };
1286
1346
  const REPAIR = "Your previous reply could not be parsed as JSON. Reply again with the complete answer as valid JSON only — no code fences, no commentary.";
1287
- async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, images, signal, normalize = (value) => value }) {
1288
- const first = await llm.complete({
1347
+ async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, images, signal, normalize = (value) => value, onText }) {
1348
+ const request = {
1289
1349
  purpose,
1290
1350
  system: prompt.system,
1291
1351
  user: prompt.user,
1292
1352
  images,
1293
1353
  maxTokens,
1294
1354
  signal
1295
- });
1355
+ };
1356
+ const first = onText ? await llm.stream({
1357
+ ...request,
1358
+ onText
1359
+ }) : await llm.complete(request);
1296
1360
  try {
1297
1361
  return normalize(extractJson(first));
1298
1362
  } catch (error) {
@@ -1369,6 +1433,20 @@ async function generateSteps(llm, { purpose = "teach", prompt, maxTokens = 12e3,
1369
1433
  }
1370
1434
  return board;
1371
1435
  }
1436
+ function streamCounter(marker, onCount) {
1437
+ let tail = "";
1438
+ let count = 0;
1439
+ return (delta) => {
1440
+ const textValue = tail + String(delta ?? "");
1441
+ let found = 0;
1442
+ for (let at = textValue.indexOf(marker); at !== -1; at = textValue.indexOf(marker, at + marker.length)) found += 1;
1443
+ tail = textValue.slice(-(marker.length - 1));
1444
+ if (found) {
1445
+ count += found;
1446
+ onCount(count);
1447
+ }
1448
+ };
1449
+ }
1372
1450
  async function generateText(llm, { purpose = "teach", prompt, maxTokens = 6e3, signal, onText }) {
1373
1451
  const textValue = onText ? await llm.stream({
1374
1452
  purpose,
@@ -1462,6 +1540,8 @@ Fill the left column, then {"t":"col"}, then the right column; start a new page
1462
1540
  const STEP_SPEC = `Each output line is ONE teaching step:
1463
1541
  {"say":"what you say while writing — natural spoken sentences, 1–4 sentences, no markdown","ops":[ …board ops written during this step… ],"check":{ …optional quick question… }}
1464
1542
 
1543
+ Write the board in time with your voice: give every op "at", the 0-based index of the sentence in "say" during which you write it, so each item appears while the sentence about it is spoken (sentences end at 。!?;.!?;). "at" never goes down within a step: {"say":"先看这个公式。它说明力与位移方向相反。","ops":[{"t":"math","tex":"F=-kx","at":0},{"t":"p","text":"负号 = 方向相反","at":1}]}
1544
+
1465
1545
  A check is a short question asked right after the step, to see whether the idea landed:
1466
1546
  {"kind":"choice|multi|blank|number|short","prompt":"…","options":["…","…"],"answer":<option index (0-based) for choice, list of indexes for multi, accepted strings for blank, a number for number, a model answer for short>,"tolerance":<number, for number>,"explanation":"why the answer is right, in one or two sentences","conceptIds":["<ids from the concept list>"]}
1467
1547
  Make wrong options plausible (common mistakes), never trick questions.`;
@@ -1535,6 +1615,7 @@ function lessonPrompt({ course, unit, lecture, lesson, language, learner, passag
1535
1615
  - Put 2–4 checks after the key ideas, plus one at the end; every check must name its conceptIds.
1536
1616
  - If source passages are given, stay consistent with them and mention the page when you lean on one.
1537
1617
  - Close with a recap step: a "key" note with the 2–3 takeaways, and say what comes next.
1618
+ - Give each step that works towards one of the objectives "goal": its 0-based index in the objectives above (${lesson.objectives.map((objective, index) => `${index} = ${objective}`).join("; ") || "none"}); leave it out for the hook and the recap. Cover the objectives in order.
1538
1619
  Start with a step whose ops begin with {"t":"title","text":"${lesson.title.replace(/"/gu, "\\\"")}"}.`)
1539
1620
  };
1540
1621
  }
@@ -1604,10 +1685,16 @@ function summaryPrompt({ scopeTitle, passages, outline, language }) {
1604
1685
  user: join(block("scope", scopeTitle), block("outline", outline), block("source_passages", passages), "Write structured study notes in Markdown: a 3–5 sentence overview, then sections with headings, key points, formulas ($…$ / $$…$$), and short examples, then a \"Check yourself\" list of 5 questions. Cite pages as (p. N) where the source says so.")
1605
1686
  };
1606
1687
  }
1607
- function digestPrompt({ title, passages, language }) {
1688
+ function digestPrompt({ title, passages, notes, language }) {
1608
1689
  return {
1609
1690
  system: join(PERSONA, `You help learners digest long readings: you surface the structure and the argument, not just a summary. ${languageRule(language)}`, JSON_ONLY),
1610
- user: join(block("document", title), block("passages", passages), "Return {\"overview\":\"3–5 sentences\",\"structure\":[{\"heading\":\"…\",\"points\":[\"…\"],\"pages\":\"e.g. 3–7\"}],\"keyIdeas\":[\"…\"],\"arguments\":[{\"claim\":\"…\",\"support\":\"…\",\"page\":\"…\"}],\"glossary\":[{\"term\":\"…\",\"meaning\":\"…\"}],\"questions\":[\"5 questions worth discussing or testing\"]}")
1691
+ user: join(block("document", title), notes ? block("notes_on_each_part", notes) : block("passages", passages), notes ? "The notes above cover the whole document part by part, in order. Combine them into one digest of the whole document: follow its structure from beginning to end, merge points that repeat, and keep the page references from the notes." : "", "Return {\"overview\":\"3–5 sentences\",\"structure\":[{\"heading\":\"…\",\"points\":[\"…\"],\"pages\":\"e.g. 3–7\"}],\"keyIdeas\":[\"…\"],\"arguments\":[{\"claim\":\"…\",\"support\":\"…\",\"page\":\"…\"}],\"glossary\":[{\"term\":\"…\",\"meaning\":\"…\"}],\"questions\":[\"5 questions worth discussing or testing\"]}")
1692
+ };
1693
+ }
1694
+ function digestPartPrompt({ title, part, parts, range, passages, language }) {
1695
+ return {
1696
+ system: join(PERSONA, `You are reading a long document one part at a time for a learner. Take faithful notes on this part only; a later pass combines the notes of all parts. ${languageRule(language)}`, JSON_ONLY),
1697
+ user: join(block("document", title), block("part", `Part ${part} of ${parts}${range ? ` (${range})` : ""}`), block("passages", passages), "Take notes on this part only. Return {\"sections\":[{\"heading\":\"the chapter or section title as the text gives it\",\"pages\":\"e.g. 12–18\",\"points\":[\"…\"]}],\"keyIdeas\":[\"…\"],\"arguments\":[{\"claim\":\"…\",\"support\":\"…\",\"page\":\"…\"}],\"glossary\":[{\"term\":\"…\",\"meaning\":\"…\"}]}. Take page numbers from the passage labels; never invent them.")
1611
1698
  };
1612
1699
  }
1613
1700
  const ASSIST_MODES = Object.freeze({
@@ -1630,7 +1717,7 @@ function shortPrompt({ topic, passages, language }) {
1630
1717
  function materialBriefPrompt({ title, passages, language }) {
1631
1718
  return {
1632
1719
  system: join(PERSONA, languageRule(language), JSON_ONLY),
1633
- user: join(block("document", title), block("passages", passages), "Describe this material for a course designer. Return {\"summary\":\"3–4 sentences\",\"topics\":[\"main topics in order\"],\"level\":\"beginner|intermediate|advanced\",\"suggestedGoal\":\"one sentence learning goal a student might have for it\"}")
1720
+ user: join(block("document", title), block("passages", passages), "The passages are excerpts spread across the whole document, each labelled with where it comes from. Describe this material as a whole for a course designer, not just its opening. Return {\"summary\":\"3–4 sentences\",\"topics\":[\"main topics in the order the document covers them\"],\"level\":\"beginner|intermediate|advanced\",\"suggestedGoal\":\"one sentence learning goal a student might have for it\"}")
1634
1721
  };
1635
1722
  }
1636
1723
  function profilePrompt({ profile, events, language }) {
@@ -1654,4 +1741,4 @@ function getRuntime() {
1654
1741
  return runtime;
1655
1742
  }
1656
1743
  //#endregion
1657
- export { normalizePractice as $, materialImages as A, boardToPlainText as B, TutorError as C, EMPTY_PROFILE as D, generateText as E, readProgress as F, flattenLessons as G, conceptIndex as H, applyEvidence as I, lessonAfter as J, insertLesson as K, createConceptState as L, passagesFor as M, readMaterials as N, emptyProgress as O, readProfile as P, normalizeCheatsheet as Q, masteryPercent as R, summaryPrompt as S, generateSteps as T, courseProgress as U, normalizeBoard as V, findLesson as W, normalizeBlueprint as X, nextLesson as Y, normalizeCards as Z, questionnairePrompt as _, blueprintPrompt as a, assertId as at, revisePrompt as b, digestPrompt as c, lessonPrompt as d, normalizeProject as et, materialBriefPrompt as f, projectsPrompt as g, projectReviewPrompt as h, assistPrompt as i, text as it, materialsSummary as j, masteryFor as k, flashcardsPrompt as l, profilePrompt as m, publishRuntime as n, outlineForPrompt as nt, cheatsheetPrompt as o, newId as ot, practicePrompt as p, intakeSummary as q, askPrompt as r, safeRelativePath as rt, detectLanguage as s, getRuntime as t, normalizeQuestionnaire as tt, gradePrompt as u, quizPrompt as v, generateJson as w, shortPrompt as x, reexplainPrompt as y, recommendNext as z };
1744
+ export { intakeSummary as $, emptyProgress as A, passageRange as B, summaryPrompt as C, generateText as D, generateSteps as E, readMaterials as F, recommendNext as G, applyEvidence as H, readProfile as I, conceptIndex as J, boardToPlainText as K, readProgress as L, materialImages as M, materialsSummary as N, streamCounter as O, passagesFor as P, insertLesson as Q, documentParts as R, shortPrompt as S, generateJson as T, createConceptState as U, spreadPassages as V, masteryPercent as W, findLesson as X, courseProgress as Y, flattenLessons as Z, projectsPrompt as _, blueprintPrompt as a, normalizePractice as at, reexplainPrompt as b, digestPartPrompt as c, outlineForPrompt as ct, gradePrompt as d, assertId as dt, lessonAfter as et, lessonPrompt as f, newId as ft, projectReviewPrompt as g, profilePrompt as h, assistPrompt as i, normalizeCheatsheet as it, masteryFor as j, EMPTY_PROFILE as k, digestPrompt as l, safeRelativePath as lt, practicePrompt as m, publishRuntime as n, normalizeBlueprint as nt, cheatsheetPrompt as o, normalizeProject as ot, materialBriefPrompt as p, normalizeBoard as q, askPrompt as r, normalizeCards as rt, detectLanguage as s, normalizeQuestionnaire as st, getRuntime as t, nextLesson as tt, flashcardsPrompt as u, text as ut, questionnairePrompt as v, TutorError as w, revisePrompt as x, quizPrompt as y, formatPassages as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-aha-tutor",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "开窍 Aha Tutor: a personal AI tutor for DeepSeek Harness that builds a course around you and teaches it on a narrated handwriting whiteboard",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "icon.png",
18
18
  "lib/*.js",
19
+ "assets/fonts/*",
19
20
  "locale/*.json",
20
21
  "samples/*.aha-course.json",
21
22
  "cordis.patch.yml",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "format": "aha-tutor.coursepack",
3
3
  "version": 1,
4
- "exportedAt": "2026-10-18T14:31:18.235Z",
4
+ "exportedAt": "2026-10-19T05:41:04.608Z",
5
5
  "app": {
6
6
  "name": "dsh-aha-tutor",
7
7
  "version": "sample"
@@ -16,7 +16,7 @@
16
16
  "goal": "不需要高深数学,从掷骰子和人数表格出发,理解条件概率、基础比率和贝叶斯公式,最后亲手写一个会“学习”的垃圾短信过滤器。",
17
17
  "units": [
18
18
  {
19
- "id": "unt_mvdxbbh690s900",
19
+ "id": "unt_mvettalbbn2kda",
20
20
  "title": "条件概率的直觉",
21
21
  "summary": "新信息会把世界变小,概率要在小世界里重新计算。",
22
22
  "objectives": [
@@ -25,12 +25,12 @@
25
25
  ],
26
26
  "lectures": [
27
27
  {
28
- "id": "lec_mvdxbbh6smcou2",
28
+ "id": "lec_mvettalbqgcijj",
29
29
  "title": "条件概率",
30
30
  "summary": "",
31
31
  "lessons": [
32
32
  {
33
- "id": "les_mvdxbbh620t7ly",
33
+ "id": "les_mvettalb3489i1",
34
34
  "title": "条件概率:知道一点之后",
35
35
  "summary": "在信息划定的小世界里重新数一遍。",
36
36
  "objectives": [
@@ -39,11 +39,11 @@
39
39
  ],
40
40
  "concepts": [
41
41
  {
42
- "id": "cpt_mvdxbbh60774lz",
42
+ "id": "cpt_mvettalbr7booh",
43
43
  "name": "条件概率"
44
44
  },
45
45
  {
46
- "id": "cpt_mvdxbbh6onehpm",
46
+ "id": "cpt_mvettalbf883xz",
47
47
  "name": "P(A|B) 与 P(B|A)"
48
48
  }
49
49
  ],
@@ -51,7 +51,7 @@
51
51
  "kind": "lesson"
52
52
  },
53
53
  {
54
- "id": "les_mvdxbbh629kqxj",
54
+ "id": "les_mvettalb5cl7sw",
55
55
  "title": "独立与不独立",
56
56
  "summary": "什么时候一个信息完全不改变另一个事件的概率。",
57
57
  "objectives": [
@@ -60,7 +60,7 @@
60
60
  ],
61
61
  "concepts": [
62
62
  {
63
- "id": "cpt_mvdxbbh6eaqmze",
63
+ "id": "cpt_mvettalbvpbysf",
64
64
  "name": "独立事件"
65
65
  }
66
66
  ],
@@ -70,12 +70,12 @@
70
70
  ]
71
71
  },
72
72
  {
73
- "id": "lec_mvdxbbh6x0qf9i",
73
+ "id": "lec_mvettalbgfqiuc",
74
74
  "title": "全概率",
75
75
  "summary": "",
76
76
  "lessons": [
77
77
  {
78
- "id": "les_mvdxbbh6uj7vv8",
78
+ "id": "les_mvettalbfzwku4",
79
79
  "title": "把世界切成几块:全概率公式",
80
80
  "summary": "分情况讨论,再把各部分加起来。",
81
81
  "objectives": [
@@ -84,7 +84,7 @@
84
84
  ],
85
85
  "concepts": [
86
86
  {
87
- "id": "cpt_mvdxbbh6zlxjif",
87
+ "id": "cpt_mvettalb90l4hz",
88
88
  "name": "全概率公式"
89
89
  }
90
90
  ],
@@ -96,7 +96,7 @@
96
96
  ]
97
97
  },
98
98
  {
99
- "id": "unt_mvdxbbh6o9j389",
99
+ "id": "unt_mvettalbhn9nwv",
100
100
  "title": "贝叶斯公式",
101
101
  "summary": "从结果反推原因,用证据更新信念。",
102
102
  "objectives": [
@@ -105,12 +105,12 @@
105
105
  ],
106
106
  "lectures": [
107
107
  {
108
- "id": "lec_mvdxbbh6m1g5km",
108
+ "id": "lec_mvettalbrgha10",
109
109
  "title": "贝叶斯",
110
110
  "summary": "",
111
111
  "lessons": [
112
112
  {
113
- "id": "les_mvdxbbh6z276z9",
113
+ "id": "les_mvettalb0yqhx7",
114
114
  "title": "医学检测悖论:基础比率的力量",
115
115
  "summary": "准确率 99% 的检测,阳性也未必意味着得病。",
116
116
  "objectives": [
@@ -119,11 +119,11 @@
119
119
  ],
120
120
  "concepts": [
121
121
  {
122
- "id": "cpt_mvdxbbh6fx86ag",
122
+ "id": "cpt_mvettalbb84add",
123
123
  "name": "基础比率"
124
124
  },
125
125
  {
126
- "id": "cpt_mvdxbbh6diki3n",
126
+ "id": "cpt_mvettalbh8eot6",
127
127
  "name": "贝叶斯公式"
128
128
  }
129
129
  ],
@@ -131,7 +131,7 @@
131
131
  "kind": "lesson"
132
132
  },
133
133
  {
134
- "id": "les_mvdxbbh6e65iqm",
134
+ "id": "les_mvettalb6i1vvh",
135
135
  "title": "先验、似然与后验",
136
136
  "summary": "贝叶斯公式里每一项的含义。",
137
137
  "objectives": [
@@ -140,11 +140,11 @@
140
140
  ],
141
141
  "concepts": [
142
142
  {
143
- "id": "cpt_mvdxbbh6xw1l7r",
143
+ "id": "cpt_mvettalbx5h9qc",
144
144
  "name": "先验与后验"
145
145
  },
146
146
  {
147
- "id": "cpt_mvdxbbh6h4x0wt",
147
+ "id": "cpt_mvettalbfgnoeb",
148
148
  "name": "似然"
149
149
  }
150
150
  ],
@@ -154,12 +154,12 @@
154
154
  ]
155
155
  },
156
156
  {
157
- "id": "lec_mvdxbbh6c9yilz",
157
+ "id": "lec_mvettalb8j0rl9",
158
158
  "title": "用起来",
159
159
  "summary": "",
160
160
  "lessons": [
161
161
  {
162
- "id": "les_mvdxbbh6a9irby",
162
+ "id": "les_mvettalbpsslfv",
163
163
  "title": "一次次更新你的判断",
164
164
  "summary": "每一个新证据,都让昨天的后验变成今天的先验。",
165
165
  "objectives": [
@@ -168,7 +168,7 @@
168
168
  ],
169
169
  "concepts": [
170
170
  {
171
- "id": "cpt_mvdxbbh6vytbdc",
171
+ "id": "cpt_mvettalb03n28y",
172
172
  "name": "贝叶斯更新"
173
173
  }
174
174
  ],
@@ -182,7 +182,7 @@
182
182
  ],
183
183
  "projects": [
184
184
  {
185
- "id": "prj_mvdxbbh6cllv6y",
185
+ "id": "prj_mvettalb98jgu4",
186
186
  "title": "写一个会学习的垃圾短信过滤器",
187
187
  "kind": "code",
188
188
  "brief": "用朴素贝叶斯的思路,统计正常短信和垃圾短信里各个词出现的频率,对一条新短信计算它是垃圾短信的后验概率。完成后,试着输入几条你自己的短信,看看过滤器的判断是否合理。",
@@ -253,12 +253,12 @@
253
253
  "content": "import csv\nimport math\nfrom collections import Counter\n\nrows = list(csv.DictReader(open(\"messages.csv\", encoding=\"utf-8\")))\n\n# TODO 1:分别统计 spam 和 ham 中每个词出现的次数\nspam_words, ham_words = Counter(), Counter()\n\n# TODO 2:计算先验 P(spam) 和 P(ham)\np_spam = None\np_ham = None\n\ndef score(text):\n \"\"\"返回 log P(spam|text) - log P(ham|text)。大于 0 判为垃圾短信。\"\"\"\n # TODO 3:对每个词累加 log 似然,记得做拉普拉斯平滑(每个词的计数 +1)\n return 0.0\n\nfor text in [\"恭喜 中奖 点击 领取\", \"明天 一起 吃饭\"]:\n print(text, \"->\", \"垃圾\" if score(text) > 0 else \"正常\")\n"
254
254
  }
255
255
  ],
256
- "unitId": "unt_mvdxbbh6o9j389"
256
+ "unitId": "unt_mvettalbhn9nwv"
257
257
  }
258
258
  ]
259
259
  },
260
260
  "boards": {
261
- "les_mvdxbbh620t7ly": {
261
+ "les_mvettalb3489i1": {
262
262
  "title": "条件概率:知道一点之后",
263
263
  "steps": [
264
264
  {
@@ -313,7 +313,7 @@
313
313
  {
314
314
  "id": "o5",
315
315
  "t": "p",
316
- "text": "新的“世界”只剩下 ==\\{2, 4, 6\\}=="
316
+ "text": "新的“世界”只剩下 ==$\\{2, 4, 6\\}$=="
317
317
  }
318
318
  ]
319
319
  },
@@ -342,7 +342,7 @@
342
342
  "hints": [],
343
343
  "difficulty": 2,
344
344
  "conceptIds": [
345
- "cpt_mvdxbbh60774lz"
345
+ "cpt_mvettalbr7booh"
346
346
  ],
347
347
  "options": [
348
348
  {
@@ -455,7 +455,7 @@
455
455
  "hints": [],
456
456
  "difficulty": 2,
457
457
  "conceptIds": [
458
- "cpt_mvdxbbh60774lz"
458
+ "cpt_mvettalbr7booh"
459
459
  ],
460
460
  "answer": 0.25,
461
461
  "tolerance": 0.005,
@@ -516,7 +516,7 @@
516
516
  "hints": [],
517
517
  "difficulty": 2,
518
518
  "conceptIds": [
519
- "cpt_mvdxbbh6onehpm"
519
+ "cpt_mvettalbf883xz"
520
520
  ],
521
521
  "options": [
522
522
  {
@@ -540,9 +540,9 @@
540
540
  }
541
541
  }
542
542
  ],
543
- "lessonId": "les_mvdxbbh620t7ly"
543
+ "lessonId": "les_mvettalb3489i1"
544
544
  },
545
- "les_mvdxbbh6z276z9": {
545
+ "les_mvettalb0yqhx7": {
546
546
  "title": "医学检测悖论:基础比率的力量",
547
547
  "steps": [
548
548
  {
@@ -578,7 +578,7 @@
578
578
  "hints": [],
579
579
  "difficulty": 2,
580
580
  "conceptIds": [
581
- "cpt_mvdxbbh6fx86ag"
581
+ "cpt_mvettalbb84add"
582
582
  ],
583
583
  "options": [
584
584
  {
@@ -739,8 +739,8 @@
739
739
  "hints": [],
740
740
  "difficulty": 2,
741
741
  "conceptIds": [
742
- "cpt_mvdxbbh6diki3n",
743
- "cpt_mvdxbbh6fx86ag"
742
+ "cpt_mvettalbh8eot6",
743
+ "cpt_mvettalbb84add"
744
744
  ],
745
745
  "answer": 50,
746
746
  "tolerance": 1,
@@ -804,7 +804,7 @@
804
804
  "hints": [],
805
805
  "difficulty": 2,
806
806
  "conceptIds": [
807
- "cpt_mvdxbbh6diki3n"
807
+ "cpt_mvettalbh8eot6"
808
808
  ],
809
809
  "options": [
810
810
  {
@@ -828,12 +828,12 @@
828
828
  }
829
829
  }
830
830
  ],
831
- "lessonId": "les_mvdxbbh6z276z9"
831
+ "lessonId": "les_mvettalb0yqhx7"
832
832
  }
833
833
  },
834
834
  "practice": {
835
- "les_mvdxbbh620t7ly": {
836
- "lessonId": "les_mvdxbbh620t7ly",
835
+ "les_mvettalb3489i1": {
836
+ "lessonId": "les_mvettalb3489i1",
837
837
  "items": [
838
838
  {
839
839
  "id": "i1",
@@ -845,7 +845,7 @@
845
845
  ],
846
846
  "difficulty": 1,
847
847
  "conceptIds": [
848
- "cpt_mvdxbbh60774lz"
848
+ "cpt_mvettalbr7booh"
849
849
  ],
850
850
  "options": [
851
851
  {
@@ -877,7 +877,7 @@
877
877
  ],
878
878
  "difficulty": 1,
879
879
  "conceptIds": [
880
- "cpt_mvdxbbh60774lz"
880
+ "cpt_mvettalbr7booh"
881
881
  ],
882
882
  "answer": 0.3,
883
883
  "tolerance": 0.001,
@@ -893,7 +893,7 @@
893
893
  ],
894
894
  "difficulty": 2,
895
895
  "conceptIds": [
896
- "cpt_mvdxbbh6onehpm"
896
+ "cpt_mvettalbf883xz"
897
897
  ],
898
898
  "options": [
899
899
  {
@@ -923,7 +923,7 @@
923
923
  "hints": [],
924
924
  "difficulty": 1,
925
925
  "conceptIds": [
926
- "cpt_mvdxbbh60774lz"
926
+ "cpt_mvettalbr7booh"
927
927
  ],
928
928
  "answer": [
929
929
  "P(B)",
@@ -939,7 +939,7 @@
939
939
  "hints": [],
940
940
  "difficulty": 2,
941
941
  "conceptIds": [
942
- "cpt_mvdxbbh6onehpm"
942
+ "cpt_mvettalbf883xz"
943
943
  ],
944
944
  "answer": "例如:P(会游泳 | 是游泳运动员) 接近 100%,但 P(是游泳运动员 | 会游泳) 很小,因为会游泳的人里只有极少数是运动员。",
945
945
  "rubric": [
@@ -950,8 +950,8 @@
950
950
  }
951
951
  ]
952
952
  },
953
- "les_mvdxbbh6z276z9": {
954
- "lessonId": "les_mvdxbbh6z276z9",
953
+ "les_mvettalb0yqhx7": {
954
+ "lessonId": "les_mvettalb0yqhx7",
955
955
  "items": [
956
956
  {
957
957
  "id": "i1",
@@ -963,7 +963,7 @@
963
963
  ],
964
964
  "difficulty": 1,
965
965
  "conceptIds": [
966
- "cpt_mvdxbbh6fx86ag"
966
+ "cpt_mvettalbb84add"
967
967
  ],
968
968
  "answer": 999,
969
969
  "tolerance": 1,
@@ -979,7 +979,7 @@
979
979
  ],
980
980
  "difficulty": 2,
981
981
  "conceptIds": [
982
- "cpt_mvdxbbh6fx86ag"
982
+ "cpt_mvettalbb84add"
983
983
  ],
984
984
  "options": [
985
985
  {
@@ -1011,7 +1011,7 @@
1011
1011
  ],
1012
1012
  "difficulty": 2,
1013
1013
  "conceptIds": [
1014
- "cpt_mvdxbbh6diki3n"
1014
+ "cpt_mvettalbh8eot6"
1015
1015
  ],
1016
1016
  "answer": 0.2687,
1017
1017
  "tolerance": 0.01,
@@ -1025,7 +1025,7 @@
1025
1025
  "hints": [],
1026
1026
  "difficulty": 1,
1027
1027
  "conceptIds": [
1028
- "cpt_mvdxbbh6diki3n"
1028
+ "cpt_mvettalbh8eot6"
1029
1029
  ],
1030
1030
  "options": [
1031
1031
  {
@@ -1055,8 +1055,8 @@
1055
1055
  "hints": [],
1056
1056
  "difficulty": 3,
1057
1057
  "conceptIds": [
1058
- "cpt_mvdxbbh6diki3n",
1059
- "cpt_mvdxbbh6fx86ag"
1058
+ "cpt_mvettalbh8eot6",
1059
+ "cpt_mvettalbb84add"
1060
1060
  ],
1061
1061
  "answer": "一次阳性后,患病的后验概率从很低升到较高(如从 0.1% 到约 9%);复查时这个后验成为新的先验,若再次阳性,患病概率会大幅提高(约 91%),判断更可靠。",
1062
1062
  "rubric": [