@khorsheed/dsh-library 0.0.0-stage → 0.1.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.en.md +49 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +48 -2
  5. package/cordis.patch.yml +17 -0
  6. package/lib/client.js +66334 -0
  7. package/lib/index.js +3318 -0
  8. package/lib/profiles-CFrPQdfO.js +1692 -0
  9. package/lib/tool.js +1597 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +3811 -0
  12. package/lib/typert.remote-client.d.ts +137 -0
  13. package/lib/typert.remote-client.js +3077 -0
  14. package/lib/types/cards.d.ts +41 -0
  15. package/lib/types/cards.js +111 -0
  16. package/lib/types/client/LibraryPane.d.ts +26 -0
  17. package/lib/types/client/LibraryPane.js +129 -0
  18. package/lib/types/client/bind/BindChip.d.ts +33 -0
  19. package/lib/types/client/bind/BindChip.js +102 -0
  20. package/lib/types/client/contract.d.ts +167 -0
  21. package/lib/types/client/contract.js +1 -0
  22. package/lib/types/client/covers.d.ts +36 -0
  23. package/lib/types/client/covers.js +66 -0
  24. package/lib/types/client/definition.d.ts +27 -0
  25. package/lib/types/client/definition.js +32 -0
  26. package/lib/types/client/icons-local.d.ts +41 -0
  27. package/lib/types/client/icons-local.js +25 -0
  28. package/lib/types/client/icons.d.ts +27 -0
  29. package/lib/types/client/icons.js +11 -0
  30. package/lib/types/client/index.d.ts +35 -0
  31. package/lib/types/client/index.js +231 -0
  32. package/lib/types/client/judge.d.ts +70 -0
  33. package/lib/types/client/judge.js +262 -0
  34. package/lib/types/client/locales.d.ts +553 -0
  35. package/lib/types/client/locales.js +1076 -0
  36. package/lib/types/client/look.d.ts +63 -0
  37. package/lib/types/client/look.js +64 -0
  38. package/lib/types/client/math.d.ts +24 -0
  39. package/lib/types/client/math.js +47 -0
  40. package/lib/types/client/mistakes.d.ts +21 -0
  41. package/lib/types/client/mistakes.js +28 -0
  42. package/lib/types/client/preset-visibility.d.ts +109 -0
  43. package/lib/types/client/preset-visibility.js +123 -0
  44. package/lib/types/client/quiz-drafts.d.ts +29 -0
  45. package/lib/types/client/quiz-drafts.js +50 -0
  46. package/lib/types/client/reveal.d.ts +21 -0
  47. package/lib/types/client/reveal.js +31 -0
  48. package/lib/types/client/store.d.ts +111 -0
  49. package/lib/types/client/store.js +48 -0
  50. package/lib/types/client/titles.d.ts +17 -0
  51. package/lib/types/client/titles.js +23 -0
  52. package/lib/types/client/ui.d.ts +96 -0
  53. package/lib/types/client/ui.js +124 -0
  54. package/lib/types/client/views/book.d.ts +57 -0
  55. package/lib/types/client/views/book.js +164 -0
  56. package/lib/types/client/views/cards.d.ts +17 -0
  57. package/lib/types/client/views/cards.js +53 -0
  58. package/lib/types/client/views/home.d.ts +26 -0
  59. package/lib/types/client/views/home.js +263 -0
  60. package/lib/types/client/views/lesson.d.ts +42 -0
  61. package/lib/types/client/views/lesson.js +146 -0
  62. package/lib/types/client/views/path.d.ts +32 -0
  63. package/lib/types/client/views/path.js +64 -0
  64. package/lib/types/client/views/quiz.d.ts +42 -0
  65. package/lib/types/client/views/quiz.js +366 -0
  66. package/lib/types/client/views/station-quiz.d.ts +43 -0
  67. package/lib/types/client/views/station-quiz.js +203 -0
  68. package/lib/types/client/views/station.d.ts +18 -0
  69. package/lib/types/client/views/station.js +142 -0
  70. package/lib/types/image-refs.d.ts +35 -0
  71. package/lib/types/image-refs.js +71 -0
  72. package/lib/types/index.d.ts +51 -0
  73. package/lib/types/index.js +31 -0
  74. package/lib/types/mastery.d.ts +81 -0
  75. package/lib/types/mastery.js +110 -0
  76. package/lib/types/profiles.d.ts +31 -0
  77. package/lib/types/profiles.js +25 -0
  78. package/lib/types/remote.d.ts +355 -0
  79. package/lib/types/remote.js +332 -0
  80. package/lib/types/service/assess.d.ts +144 -0
  81. package/lib/types/service/assess.js +773 -0
  82. package/lib/types/service/bindings.d.ts +92 -0
  83. package/lib/types/service/bindings.js +198 -0
  84. package/lib/types/service/cards.d.ts +85 -0
  85. package/lib/types/service/cards.js +320 -0
  86. package/lib/types/service/core.d.ts +124 -0
  87. package/lib/types/service/core.js +169 -0
  88. package/lib/types/service/pages.d.ts +60 -0
  89. package/lib/types/service/pages.js +125 -0
  90. package/lib/types/service/roadmap.d.ts +99 -0
  91. package/lib/types/service/roadmap.js +284 -0
  92. package/lib/types/service/rules.d.ts +63 -0
  93. package/lib/types/service/rules.js +245 -0
  94. package/lib/types/service/shelf.d.ts +136 -0
  95. package/lib/types/service/shelf.js +422 -0
  96. package/lib/types/service.d.ts +42 -0
  97. package/lib/types/service.js +41 -0
  98. package/lib/types/store.d.ts +257 -0
  99. package/lib/types/store.js +1376 -0
  100. package/lib/types/tool-definition.d.ts +49 -0
  101. package/lib/types/tool-definition.js +49 -0
  102. package/lib/types/tool.d.ts +71 -0
  103. package/lib/types/tool.js +174 -0
  104. package/lib/types/tools/assess.d.ts +11 -0
  105. package/lib/types/tools/assess.js +82 -0
  106. package/lib/types/tools/binding.d.ts +11 -0
  107. package/lib/types/tools/binding.js +46 -0
  108. package/lib/types/tools/cards.d.ts +11 -0
  109. package/lib/types/tools/cards.js +100 -0
  110. package/lib/types/tools/kit.d.ts +215 -0
  111. package/lib/types/tools/kit.js +281 -0
  112. package/lib/types/tools/notes.d.ts +11 -0
  113. package/lib/types/tools/notes.js +40 -0
  114. package/lib/types/tools/page.d.ts +11 -0
  115. package/lib/types/tools/page.js +59 -0
  116. package/lib/types/tools/project.d.ts +11 -0
  117. package/lib/types/tools/project.js +67 -0
  118. package/lib/types/tools/quiz.d.ts +11 -0
  119. package/lib/types/tools/quiz.js +74 -0
  120. package/lib/types/tools/read.d.ts +11 -0
  121. package/lib/types/tools/read.js +127 -0
  122. package/lib/types/tools/resource.d.ts +11 -0
  123. package/lib/types/tools/resource.js +147 -0
  124. package/lib/types/tools/roadmap.d.ts +11 -0
  125. package/lib/types/tools/roadmap.js +102 -0
  126. package/lib/types/types.d.ts +752 -0
  127. package/lib/types/types.js +159 -0
  128. package/locale/en.json +6 -0
  129. package/locale/zh.json +6 -0
  130. package/package.json +135 -3
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Assessments and attempts: quizzes (the two-axis checks), the assess cascade, acceptance sittings.
3
+ *
4
+ * One layer of the library service (see `service/core.ts` for the layering):
5
+ * the methods are the service's own, moved here verbatim by domain.
6
+ *
7
+ * @module @khorsheed/dsh-library/service/assess
8
+ */
9
+ import { DEFAULT_PASS_RATIO, isLibraryId, acceptanceLines, coverKind, isThickResource, MAX_BOX, } from "../types.js";
10
+ import { cardKey, exactMatch } from "../cards.js";
11
+ import { mintLibraryId } from "../store.js";
12
+ import { refuse, leitnerDueAt, normalizeFragment, clampWeakness, live, } from "./core.js";
13
+ import { titleOf, choiceOptionIndex, unlockReady, coversVerbatimBlocker } from "./rules.js";
14
+ import { masteryChanges, stationMastery } from "../mastery.js";
15
+ import { LibraryServicePages } from "./pages.js";
16
+ export class LibraryServiceAssess extends LibraryServicePages {
17
+ /* ---------------------------------------------------------- assessments */
18
+ /** A project's assessments, optionally one resource's / node's / one type's. */
19
+ async listAssessments(request) {
20
+ this.assertAlive('listAssessments');
21
+ return {
22
+ assessments: this.store.listAssessments(request.projectId).filter(assessment => (request.resourceId === undefined || assessment.scope.resourceId === request.resourceId)
23
+ && (request.nodeId === undefined || assessment.scope.nodeId === request.nodeId)
24
+ && (request.type === undefined || assessment.type === request.type)),
25
+ };
26
+ }
27
+ /** One assessment, whole. */
28
+ async getAssessment(request) {
29
+ this.assertAlive('getAssessment');
30
+ const assessment = this.store.readAssessment(request.projectId, request.assessmentId);
31
+ if (assessment === undefined)
32
+ return refuse('not-found', `no assessment ${JSON.stringify(request.assessmentId)} — library_read · quiz without an id lists the project's`);
33
+ return { ok: true, assessment };
34
+ }
35
+ /**
36
+ * Store a quiz behind the two-axis checks. Target: a marked / asked / missed
37
+ * question names the live cards it probes. Evidence: a photo quote names a
38
+ * recorded page (a quote the page text lacks comes back as a warning); a web
39
+ * quote carries its url; whether domain knowledge may back a CONTENT question
40
+ * is judged on the gated node's covers (a material book, a thin link or a
41
+ * bare url among them demands a verbatim quote) — a resource-scoped quiz
42
+ * without a node keeps the resource-level rule, and a scopeless free quiz
43
+ * allows knowledge. A node-scoped quiz supersedes the station's live one.
44
+ */
45
+ async putQuiz(request) {
46
+ this.assertAlive('putQuiz');
47
+ const project = this.readProjectOrRefusal(request.projectId);
48
+ if ('error' in project)
49
+ return project;
50
+ const scope = request.quiz.scope;
51
+ const resource = scope.resourceId === undefined ? undefined : this.store.readResource(project.value.id, scope.resourceId);
52
+ if (scope.resourceId !== undefined && resource === undefined) {
53
+ return refuse('not-found', `quiz scope names resource ${JSON.stringify(scope.resourceId)}, which project ${JSON.stringify(project.value.name)} does not hold — library_read · resources lists them`);
54
+ }
55
+ if (request.quiz.focus !== undefined && !['mine', 'book', 'mixed'].includes(request.quiz.focus)) {
56
+ return refuse('invalid', 'quiz.focus is mine | book | mixed — mine: only the learner\'s own cards; book: only the chapter\'s content');
57
+ }
58
+ if (request.quiz.questions.length === 0) {
59
+ return refuse('invalid', 'a quiz needs at least one question — each: { id, kind, prompt, options?, answer, explanation?, target: { kind, cardIds? }, evidence: { kind, pageId?, url?, quote? } }');
60
+ }
61
+ const nodeId = scope.nodeId;
62
+ let lockedNote;
63
+ let gatedNode;
64
+ let gatedRoadmap;
65
+ if (nodeId !== undefined) {
66
+ // The node resolves on the scoped textbook's adopted path, or — a
67
+ // scope-less station quiz — on whichever textbook's path holds it.
68
+ const roadmaps = scope.resourceId !== undefined
69
+ ? [this.store.readRoadmap(project.value.id, scope.resourceId)]
70
+ : this.store.listResources(project.value.id).filter(isThickResource).map(candidate => this.store.readRoadmap(project.value.id, candidate.id));
71
+ for (const roadmap of roadmaps) {
72
+ const node = roadmap?.nodes.find(candidate => candidate.id === nodeId);
73
+ if (node !== undefined) {
74
+ gatedRoadmap = roadmap;
75
+ gatedNode = node;
76
+ break;
77
+ }
78
+ }
79
+ if (gatedNode === undefined || gatedRoadmap === undefined)
80
+ return refuse('not-found', `quiz scope names node ${JSON.stringify(nodeId)}, which no adopted path holds — library_read · roadmap lists the nodes`);
81
+ if (gatedNode.gate === 'read')
82
+ return refuse('invalid', `${gatedNode.title} is a read node — it has no quiz gate; quiz a quiz-gated node, or leave scope.node off for a free practice quiz`);
83
+ if (gatedNode.gate === 'acceptance')
84
+ return refuse('invalid', `${gatedNode.title} is an acceptance station — it completes by acceptance (library_assess with node), not by a quiz; to gate it ON a quiz, compose the quiz without a node (resource-level or free), then reference it from the station's acceptance: { line: "…", ref: "<quiz id>" } — the line auto-passes when the quiz passes`);
85
+ if (gatedNode.state === 'locked')
86
+ lockedNote = `${gatedNode.title} is still locked — the learner can take this quiz, but passing it completes the node only once the nodes before it are done`;
87
+ }
88
+ // Points: the gated station's split (drafted with the roadmap, reviewed at
89
+ // adoption), plus any addPoints this quiz establishes. Every question names
90
+ // the point it probes once the station has any; mastery aggregates by id.
91
+ const pointAdds = [];
92
+ if (request.quiz.addPoints !== undefined && request.quiz.addPoints.length > 0) {
93
+ if (gatedNode === undefined)
94
+ return refuse('invalid', 'addPoints belongs to a station quiz (scope.node) — a resource-level or free quiz has no points to extend');
95
+ const seen = new Set((gatedNode.points ?? []).map(point => point.id));
96
+ for (const point of request.quiz.addPoints) {
97
+ if (!isLibraryId(point.id))
98
+ return refuse('invalid', `point id ${JSON.stringify(point.id)} is not a safe slug (letters, digits, . _ -)`);
99
+ if (typeof point.title !== 'string' || point.title.trim() === '')
100
+ return refuse('invalid', `point ${point.id} needs a title — the point as the learner reads it (「极限的定义」)`);
101
+ if (seen.has(point.id))
102
+ return refuse('invalid', `point ${point.id} already exists on ${gatedNode.title} — reference it (point: ${JSON.stringify(point.id)}), never re-add`);
103
+ seen.add(point.id);
104
+ pointAdds.push({ id: point.id, title: point.title.trim() });
105
+ }
106
+ }
107
+ const knownPoints = [...(gatedNode?.points ?? []), ...pointAdds];
108
+ const knownPointIds = new Set(knownPoints.map(point => point.id));
109
+ const liveCards = new Set(this.store.readCards(project.value.id).filter(live).map(card => card.id));
110
+ // Photo evidence names a page; the pool is the scoped resource's pages, the
111
+ // gated node's covered resources' pages, or (a free quiz) the whole project's.
112
+ const pageResourceIds = resource !== undefined
113
+ ? [resource.id]
114
+ : gatedNode !== undefined
115
+ ? [...new Set(gatedNode.covers.flatMap(cover => coverKind(cover) === 'resource' ? [cover.ref] : []))]
116
+ : this.store.listResources(project.value.id).map(candidate => candidate.id);
117
+ const pages = new Map(pageResourceIds.flatMap(candidate => this.store.readPages(project.value.id, candidate).map(page => [page.id, page])));
118
+ const seenIds = new Set();
119
+ const warnings = lockedNote === undefined ? [] : [lockedNote];
120
+ // Every question's problems are collected and reported at once: a quiz
121
+ // with four bad questions earns ONE refusal listing all four, not four
122
+ // fail-fast round trips (each retry resends the whole questions payload).
123
+ const errors = [];
124
+ for (const [index, question] of request.quiz.questions.entries()) {
125
+ const label = `questions[${index}]${question.id === '' ? '' : ` (${question.id})`}`;
126
+ if (question.id === '' || seenIds.has(question.id))
127
+ errors.push(`${label} needs a unique id — q1, q2, … is plenty`);
128
+ seenIds.add(question.id);
129
+ if (!['choice', 'fill', 'free'].includes(question.kind))
130
+ errors.push(`${label}.kind is choice | fill | free`);
131
+ if (question.prompt.trim() === '' || question.answer.trim() === '') {
132
+ errors.push(`${label} needs both a prompt and an answer — a question without its answer cannot be graded`);
133
+ }
134
+ if (question.kind === 'choice' && (question.options === undefined || question.options.length < 2)) {
135
+ errors.push(`${label} is a choice question with fewer than two options`);
136
+ }
137
+ else if (question.kind === 'choice' && question.options !== undefined && choiceOptionIndex(question.options, question.answer) < 0) {
138
+ errors.push(`${label}.answer matches no option — a choice question's answer IS one of its options, copied verbatim (or its letter); write the correct option first, then the distractors around it`);
139
+ }
140
+ const target = question.target;
141
+ const targetOk = target !== undefined && ['marked', 'asked', 'missed', 'topic'].includes(target.kind);
142
+ if (!targetOk) {
143
+ errors.push(`${label} needs target: { kind: marked|asked|missed|topic, cardIds? } — what the question aims at: the learner's marked cards, the points they asked about, the ones they missed before, or the chapter's topic`);
144
+ }
145
+ else {
146
+ const focus = request.quiz.focus;
147
+ if (focus === 'mine' && target.kind === 'topic') {
148
+ errors.push(`${label} tests the chapter's topic, but the learner asked for their OWN points only (focus: mine) — every question must target their cards (marked / asked / missed, with cardIds from library_read · cards). If this node has too few cards, write fewer questions and tell the learner; never pad with book content`);
149
+ }
150
+ if (focus === 'book' && target.kind !== 'topic') {
151
+ errors.push(`${label} targets the learner's cards, but they asked for the book's content only (focus: book) — target: { kind: "topic" } for every question`);
152
+ }
153
+ if (target.kind !== 'topic') {
154
+ if (target.cardIds === undefined || target.cardIds.length === 0) {
155
+ errors.push(`${label} targets ${target.kind} cards but names none — target.cardIds lists the cards it probes (library_read · cards finds them)`);
156
+ }
157
+ else {
158
+ const unknown = target.cardIds.filter(cardId => !liveCards.has(cardId));
159
+ if (unknown.length > 0)
160
+ errors.push(`${label} names cards that do not exist: ${unknown.join(', ')} — use ids from library_read · cards`);
161
+ }
162
+ }
163
+ }
164
+ if (question.point !== undefined) {
165
+ if (gatedNode === undefined)
166
+ errors.push(`${label} names point ${JSON.stringify(question.point)}, but points belong to a station quiz (scope.node) — a resource-level or free quiz carries none`);
167
+ else if (!knownPointIds.has(question.point))
168
+ errors.push(`${label} probes point ${JSON.stringify(question.point)}, which ${gatedNode.title} does not hold — its points are: ${knownPoints.map(point => `${point.id} (${point.title})`).join(', ') || 'none yet'}; reuse one or add it via addPoints`);
169
+ }
170
+ else if (knownPoints.length > 0) {
171
+ errors.push(`${label} names no point — this station's quizzes tag every question with the point it probes (point: one of ${[...knownPointIds].join(', ')})`);
172
+ }
173
+ const evidence = question.evidence;
174
+ if (evidence === undefined || !['photo', 'web', 'knowledge'].includes(evidence.kind)) {
175
+ errors.push(`${label} needs evidence: { kind: photo|web|knowledge, … } — what the answer rests on`);
176
+ }
177
+ else {
178
+ if (evidence.kind === 'photo') {
179
+ const page = evidence.pageId === undefined ? undefined : pages.get(evidence.pageId);
180
+ if (page === undefined) {
181
+ errors.push(`${label} rests on a photo but names no recorded page — evidence.pageId comes from library_read · pages`);
182
+ }
183
+ else {
184
+ const quote = normalizeFragment(evidence.quote ?? '');
185
+ if (quote.length < 4)
186
+ errors.push(`${label} rests on a photo — quote the printed passage (evidence.quote) the answer comes from`);
187
+ else if (!normalizeFragment(page.text).includes(quote))
188
+ warnings.push(`${label}: its quote is not verbatim in page ${page.label ?? page.id}'s text — stored; re-read the page and fix the quote`);
189
+ }
190
+ }
191
+ if (evidence.kind === 'web') {
192
+ if (evidence.url === undefined || !/^https?:\/\//.test(evidence.url))
193
+ errors.push(`${label} rests on the web — evidence.url is the page the quote is from`);
194
+ else if (normalizeFragment(evidence.quote ?? '').length < 8)
195
+ errors.push(`${label} rests on the web — quote the passage (a sentence or two) so the learner can check it`);
196
+ }
197
+ if (evidence.kind === 'knowledge' && targetOk && target.kind === 'topic') {
198
+ // The gate sits on the node's covers now: a station drawing only on
199
+ // knowledge books / plain teaching may be content-quizzed from memory;
200
+ // a material book, a thin link or a bare url among the covers demands
201
+ // a verbatim quote. No node in scope = the resource-level rule.
202
+ if (gatedNode === undefined) {
203
+ if (resource !== undefined && isThickResource(resource) && resource.meta?.bookKind === 'material') {
204
+ errors.push(`${label} asks about 《${resource.title}》's content from memory — it is a material book (its exact text is what is tested). Ask the learner to photograph the page and quote it (evidence.kind photo); a web quote only from the SAME edition's text, verbatim — never your own words as a quote. If the book is really a textbook of domain knowledge, it is mis-typed: the learner switches it to 知识型 on the textbook page`);
205
+ }
206
+ }
207
+ else {
208
+ const blocker = coversVerbatimBlocker(gatedNode.covers, candidate => this.store.readResource(project.value.id, candidate), gatedRoadmap?.primaryResourceId);
209
+ if (blocker !== undefined) {
210
+ errors.push(`${label} asks the station's content from memory, but ${blocker}. Ask the learner to photograph the page and quote it (evidence.kind photo); a web quote only from the same text, verbatim — never your own words as a quote, never material from another project`);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ // Math that never typesets: the fields smell like math (unicode symbols,
216
+ // \commands, ^digits) but carry no $…$ at all — warn, never block.
217
+ const text = [question.prompt, question.answer, ...(question.options ?? []), question.explanation ?? ''].join(' ');
218
+ if (!text.includes('$') && /[√∫∑∏∂∞±]|\\[a-zA-Z]{2,}|\^\{|\w\^\d/.test(text)) {
219
+ warnings.push(`${label} reads like math without any $…$ — the sidebar typesets LaTeX only inside $…$ / $$…$$, so this shows as raw text; rewrite the formula parts (e.g. $\\sqrt{x+1}$)`);
220
+ }
221
+ }
222
+ if (errors.length === 1)
223
+ return refuse('evidence-gate', errors[0]);
224
+ if (errors.length > 1) {
225
+ return refuse('evidence-gate', `${errors.length} problems in questions — fix them all, then resend:\n${errors.map(error => `- ${error}`).join('\n')}`);
226
+ }
227
+ // A full chapter quiz touches every point; a retake legitimately does not — warn, never block.
228
+ const probed = new Set(request.quiz.questions.map(question => question.point));
229
+ const uncovered = knownPoints.filter(point => !probed.has(point.id));
230
+ if (uncovered.length > 0)
231
+ warnings.push(`never asks about ${uncovered.map(point => point.title).join(', ')} — fine for a retake; a full chapter quiz touches every point at least once`);
232
+ if (nodeId === undefined) {
233
+ warnings.push(resource === undefined
234
+ ? 'stored as a free quiz (no station, no resource): the learner finds it on the project page (自由测验) and it gates nothing — name node (a quiz-gated station) to gate one'
235
+ : `stored as a resource-level quiz of 《${resource.title}》 (no station): the learner finds it on the resource page (本书测验) — name node (a quiz-gated station) to gate one`);
236
+ }
237
+ return await this.mutate(project.value.id, () => {
238
+ const now = new Date().toISOString();
239
+ const quiz = {
240
+ id: mintLibraryId('q'),
241
+ type: 'quiz',
242
+ scope: { ...(resource === undefined ? {} : { resourceId: resource.id }), ...(nodeId === undefined ? {} : { nodeId }) },
243
+ ...(request.quiz.focus === undefined ? {} : { focus: request.quiz.focus }),
244
+ questions: request.quiz.questions,
245
+ createdAt: now,
246
+ };
247
+ this.store.writeAssessment(project.value.id, quiz);
248
+ // A node's new paper supersedes the station's live one (its sittings
249
+ // stay attached to the old snapshot — history never orphans).
250
+ if (gatedNode !== undefined) {
251
+ for (const assessment of this.store.listAssessments(project.value.id)) {
252
+ if (assessment.type === 'quiz' && assessment.scope.nodeId === gatedNode.id && assessment.supersededBy === undefined) {
253
+ this.store.writeAssessment(project.value.id, { ...assessment, supersededBy: quiz.id });
254
+ }
255
+ }
256
+ }
257
+ // addPoints land on the adopted path's node (the draft is long gone).
258
+ if (pointAdds.length > 0 && gatedNode !== undefined && gatedRoadmap !== undefined) {
259
+ const nodes = gatedRoadmap.nodes.map(node => node.id === gatedNode.id ? { ...node, points: [...(node.points ?? []), ...pointAdds] } : node);
260
+ this.store.writeRoadmap(project.value.id, { ...gatedRoadmap, nodes });
261
+ }
262
+ this.event(project.value.id, 'quiz.created', { at: quiz.createdAt, id: quiz.id, ...(resource === undefined ? {} : { resourceId: resource.id }), count: request.quiz.questions.length, ...(pointAdds.length === 0 ? {} : { count2: pointAdds.length }) });
263
+ return { ok: true, quiz, warnings };
264
+ });
265
+ }
266
+ /* ------------------------------------------------------------- attempts */
267
+ /**
268
+ * Grade a sitting — the only cascading write. Two shapes, exactly one key:
269
+ * `assessmentId` (or the legacy alias `quizId`) judges a stored quiz;
270
+ * `nodeId` accepts an ACCEPTANCE station — resolved to the node's current
271
+ * practice assessment, each verdict's task naming one rubric line (its id
272
+ * L1…, or the line verbatim).
273
+ *
274
+ * Void verdicts count for nothing. Every probed card moves: a pass climbs a
275
+ * Leitner box and eases weakness by one; a fail resets to box 0 (due
276
+ * tomorrow) and adds weakness. A failed line with no card gets one
277
+ * (`missedCard`). The gated node's stats update (quiz nodes); at or above
278
+ * the pass ratio it completes and unlocks its dependents.
279
+ */
280
+ async putAttempt(request) {
281
+ this.assertAlive('putAttempt');
282
+ const project = this.readProjectOrRefusal(request.projectId);
283
+ if ('error' in project)
284
+ return project;
285
+ const assessmentId = request.assessmentId ?? request.quizId;
286
+ const nodeId = request.nodeId;
287
+ if ((assessmentId === undefined) === (nodeId === undefined)) {
288
+ return refuse('invalid', 'assess takes exactly one of quiz / node — quiz: a graded sitting on a stored quiz; node: acceptance of an acceptance station');
289
+ }
290
+ if (request.verdicts.length === 0) {
291
+ return refuse('invalid', 'library_assess needs the verdicts — one per question: { task: <question id>, criterion, pass, ratio?, evidence, by, void?, missedCard? }');
292
+ }
293
+ if (assessmentId !== undefined)
294
+ return await this.putQuizAttempt(project.value, assessmentId, request.verdicts);
295
+ return await this.putPracticeAttempt(project.value, nodeId ?? '', request.resourceId, request.verdicts);
296
+ }
297
+ /** The quiz path of putAttempt: verdicts judged against a stored quiz's questions. */
298
+ async putQuizAttempt(project, assessmentId, verdicts) {
299
+ const assessment = this.store.readAssessment(project.id, assessmentId);
300
+ if (assessment === undefined || (assessment.type !== 'quiz' && assessment.type !== 'retake') || assessment.questions === undefined)
301
+ return refuse('not-found', `no quiz ${JSON.stringify(assessmentId)} — store the quiz first (library_quiz), then assess it`);
302
+ const questionById = new Map(assessment.questions.map(question => [question.id, question]));
303
+ for (const [index, verdict] of verdicts.entries()) {
304
+ if (!questionById.has(verdict.task)) {
305
+ return refuse('invalid', `verdicts[${index}] judges task ${JSON.stringify(verdict.task)}, which quiz ${assessment.id} does not ask — its questions are: ${assessment.questions.map(question => question.id).join(', ')}`);
306
+ }
307
+ if (verdict.criterion.trim() === '' || verdict.evidence.trim() === '') {
308
+ return refuse('invalid', `verdicts[${index}] (${verdict.task}) needs criterion and evidence — WHAT was judged and WHERE the answer is grounded`);
309
+ }
310
+ if (verdict.by !== 'llm' && verdict.by !== 'self' && verdict.by !== 'user')
311
+ return refuse('invalid', `verdicts[${index}].by must be llm | self | user`);
312
+ if (verdict.ratio !== undefined && !(verdict.ratio >= 0 && verdict.ratio <= 1))
313
+ return refuse('invalid', `verdicts[${index}].ratio is partial credit in [0, 1]`);
314
+ }
315
+ const counted = verdicts.filter(verdict => verdict.void !== true);
316
+ const ratio = counted.length === 0 ? 0 : counted.reduce((sum, verdict) => sum + (verdict.ratio ?? (verdict.pass ? 1 : 0)), 0) / counted.length;
317
+ return await this.mutate(project.id, () => {
318
+ const now = new Date();
319
+ const at = now.toISOString();
320
+ // The stations this sitting can move: its own, or — a project-wide retake — every station its questions came from.
321
+ const stationIds = assessment.scope.nodeId !== undefined
322
+ ? [assessment.scope.nodeId]
323
+ : [...new Set((assessment.questions ?? []).flatMap(question => {
324
+ const origin = question.origin === undefined ? undefined : this.store.readAssessment(project.id, question.origin.assessmentId);
325
+ return origin?.scope.nodeId === undefined ? [] : [origin.scope.nodeId];
326
+ }))];
327
+ const befores = stationIds.map(stationId => [stationId, this.stationMasteryOf(project.id, stationId, stationId === assessment.scope.nodeId ? assessment.scope.resourceId : undefined)]);
328
+ const attempt = { id: mintLibraryId('a'), assessmentId: assessment.id, verdicts, ratio, at };
329
+ this.store.appendAttempt(project.id, attempt);
330
+ // Cards: every counted verdict moves the cards its question probed.
331
+ const cards = this.store.readCards(project.id);
332
+ const byId = new Map(cards.map(card => [card.id, card]));
333
+ const order = cards.map(card => card.id);
334
+ const touched = new Map();
335
+ const resource = assessment.scope.resourceId === undefined ? undefined : this.store.readResource(project.id, assessment.scope.resourceId);
336
+ for (const verdict of counted) {
337
+ const question = questionById.get(verdict.task);
338
+ const credit = verdict.ratio ?? (verdict.pass ? 1 : 0);
339
+ const passed = verdict.pass && credit >= 0.5;
340
+ const cardIds = [...(question.target.cardIds ?? [])];
341
+ // A card-less miss leaves a card only when the coach named what was
342
+ // missed (missedCard) — never the question's own text: a subject's
343
+ // misses wait in 待订正 on their point instead.
344
+ if (cardIds.length === 0 && !passed && (verdict.missedCard?.term.trim() ?? '') !== '') {
345
+ const created = this.missedCardFor(question, verdict, assessment, resource?.id, project.template, at);
346
+ const existing = exactMatch([...byId.values()], [cardKey(created.term)]);
347
+ if (existing === undefined) {
348
+ byId.set(created.id, created);
349
+ order.push(created.id);
350
+ touched.set(created.id, { id: created.id, term: created.term, weakness: created.weakness, mastery: created.mastery, created: true });
351
+ continue;
352
+ }
353
+ cardIds.push(existing.id);
354
+ }
355
+ for (const cardId of cardIds) {
356
+ const card = byId.get(cardId);
357
+ if (card === undefined || !live(card))
358
+ continue;
359
+ const box = passed ? Math.min(card.mastery.box + 1, MAX_BOX) : 0;
360
+ const next = {
361
+ ...card,
362
+ mastery: { box, dueAt: leitnerDueAt(box, now), streak: passed ? card.mastery.streak + 1 : 0 },
363
+ weakness: clampWeakness(card.weakness + (passed ? -1 : 1)),
364
+ sources: passed ? card.sources : [...card.sources, { type: 'quiz', ref: assessment.id, note: verdict.criterion.slice(0, 120), weakened: true, at }],
365
+ updatedAt: at,
366
+ };
367
+ byId.set(card.id, next);
368
+ touched.set(card.id, { id: card.id, term: card.term, weakness: next.weakness, mastery: next.mastery });
369
+ }
370
+ }
371
+ if (touched.size > 0)
372
+ this.store.writeCards(project.id, order.map(cardId => byId.get(cardId)));
373
+ // The gated node's stats, its completion, and what that unlocks.
374
+ let unlocked = [];
375
+ let completed;
376
+ const roadmap = assessment.scope.nodeId === undefined ? undefined : this.findNodeRoadmap(project.id, assessment.scope.nodeId, assessment.scope.resourceId);
377
+ const passRatio = roadmap?.passRatio ?? DEFAULT_PASS_RATIO;
378
+ const gated = assessment.scope.nodeId === undefined ? undefined : roadmap?.nodes.find(node => node.id === assessment.scope.nodeId);
379
+ // A retake (订正) never counts toward passing: no stats, no completion.
380
+ if (roadmap !== undefined && gated !== undefined && gated.gate === 'quiz' && assessment.type === 'quiz') {
381
+ const updated = roadmap.nodes.map(node => {
382
+ if (node.id !== gated.id)
383
+ return node;
384
+ const stats = node.quizStats ?? { attempts: 0 };
385
+ const next = {
386
+ ...node,
387
+ quizStats: { attempts: stats.attempts + 1, lastRatio: ratio, bestRatio: Math.max(stats.bestRatio ?? 0, ratio) },
388
+ };
389
+ if (ratio >= passRatio && node.state === 'current') {
390
+ completed = node.id;
391
+ return { ...next, state: 'done' };
392
+ }
393
+ return next;
394
+ });
395
+ const result = completed === undefined ? { nodes: updated, unlocked: [] } : unlockReady(updated);
396
+ unlocked = result.unlocked;
397
+ this.store.writeRoadmap(project.id, { ...roadmap, nodes: result.nodes });
398
+ }
399
+ const created = [...touched.values()].filter(update => update.created === true);
400
+ const onResource = assessment.scope.resourceId === undefined ? {} : { resourceId: assessment.scope.resourceId };
401
+ const pointChanges = befores.flatMap(([stationId, before]) => this.pointChangesSince(project.id, stationId, stationId === assessment.scope.nodeId ? assessment.scope.resourceId : undefined, before, at));
402
+ this.store.appendEvents(project.id, [
403
+ { at, type: 'attempt.assessed', id: assessment.id, ...onResource, ratio },
404
+ ...(created.length === 0 ? [] : [{
405
+ at,
406
+ type: 'cards.recorded',
407
+ source: 'quiz',
408
+ title: created.map(update => update.term).join('、').slice(0, 300),
409
+ ids: created.map(update => update.id),
410
+ ...onResource,
411
+ count: created.length,
412
+ }]),
413
+ ...(completed === undefined ? [] : [{ at, type: 'node.completed', id: completed, title: titleOf(roadmap?.nodes ?? [], completed), ...onResource, ratio }]),
414
+ ...unlocked.map(nodeId => ({ at, type: 'node.unlocked', id: nodeId, title: titleOf(roadmap?.nodes ?? [], nodeId), ...onResource })),
415
+ ]);
416
+ return {
417
+ ok: true,
418
+ attempt,
419
+ cardUpdates: [...touched.values()],
420
+ unlocked,
421
+ ...(completed === undefined ? {} : { completed }),
422
+ passRatio,
423
+ ...(pointChanges.length === 0 ? {} : { pointChanges }),
424
+ };
425
+ });
426
+ }
427
+ /**
428
+ * The practice path of putAttempt: acceptance of a hands-on station, judged
429
+ * against the node's current practice assessment (materialized at adoption).
430
+ * No probed cards — a failed acceptance line with a `missedCard` leaves (or
431
+ * re-weakens) a card; at the pass ratio the station completes.
432
+ */
433
+ async putPracticeAttempt(project, nodeId, resourceId, verdicts) {
434
+ const roadmap = this.findNodeRoadmap(project.id, nodeId, resourceId);
435
+ const node = roadmap?.nodes.find(candidate => candidate.id === nodeId);
436
+ if (roadmap === undefined || node === undefined) {
437
+ return refuse('not-found', `no station ${JSON.stringify(nodeId)} on the adopted path — library_read · roadmap lists the nodes`);
438
+ }
439
+ if (node.gate !== 'acceptance') {
440
+ return refuse('state-machine', `${node.title} is a ${node.gate} node — a quiz node completes by passing its quiz (assess with quiz), a read node by 读完; only an acceptance station takes acceptance verdicts`);
441
+ }
442
+ const assessment = this.store.listAssessments(project.id)
443
+ .find(candidate => candidate.type === 'practice' && candidate.scope.nodeId === node.id && candidate.supersededBy === undefined);
444
+ // The rubric the verdicts judge: the materialized assessment's snapshot,
445
+ // else (an adopted-before-v5 path that never materialized) the node's own
446
+ // acceptance lines.
447
+ const rubric = assessment?.rubric ?? acceptanceLines(node);
448
+ if (rubric.length === 0)
449
+ return refuse('invalid', `${node.title} carries no acceptance lines — the coach drafts them into the roadmap (library_roadmap)`);
450
+ const passRatio = roadmap.passRatio ?? DEFAULT_PASS_RATIO;
451
+ // Machine-checked lines: the referenced assessment's latest sitting does the judging.
452
+ const attempts = this.store.readAttempts(project.id);
453
+ const auto = [];
454
+ const autoTasks = new Set();
455
+ for (const line of rubric) {
456
+ if (line.ref === undefined)
457
+ continue;
458
+ const referenced = this.store.readAssessment(project.id, line.ref);
459
+ const latest = referenced === undefined ? undefined : attempts.filter(attempt => attempt.assessmentId === referenced.id).at(-1);
460
+ const quizRatio = latest?.ratio ?? 0;
461
+ auto.push({
462
+ task: line.id,
463
+ criterion: line.line,
464
+ pass: latest !== undefined && quizRatio >= passRatio,
465
+ ratio: quizRatio,
466
+ evidence: latest === undefined ? `机检:${line.ref} 还没有判分记录` : `机检:${line.ref} 最新一场 ${Math.round(quizRatio * 100)}%(过线 ${Math.round(passRatio * 100)}%)`,
467
+ by: 'auto',
468
+ });
469
+ autoTasks.add(line.id);
470
+ }
471
+ // A coach verdict resolves its line by id (L1, L2, …) or by the line's
472
+ // verbatim text (the legacy form); a ref line is the machine's, never the
473
+ // coach's. Judged verdicts are ledgered with the canonical id + text.
474
+ const judged = [];
475
+ for (const [index, verdict] of verdicts.entries()) {
476
+ const line = rubric.find(candidate => candidate.id === verdict.task) ?? rubric.find(candidate => candidate.line === verdict.criterion);
477
+ if (line === undefined) {
478
+ return refuse('invalid', `verdicts[${index}] names no rubric line — task is the line id, criterion the line verbatim; the rubric is: ${rubric.map(candidate => `${candidate.id} ${JSON.stringify(candidate.line)}`).join(', ')}`);
479
+ }
480
+ if (autoTasks.has(line.id)) {
481
+ return refuse('invalid', `verdicts[${index}] judges ${line.id}, which assessment ${line.ref} machine-checks — a ref line takes no verdict`);
482
+ }
483
+ if (verdict.evidence.trim() === '') {
484
+ return refuse('invalid', `verdicts[${index}] needs evidence — what the learner showed (terminal output, a screenshot, a repo link)`);
485
+ }
486
+ if (verdict.by !== 'llm' && verdict.by !== 'self' && verdict.by !== 'user')
487
+ return refuse('invalid', `verdicts[${index}].by must be llm | self | user`);
488
+ if (verdict.ratio !== undefined && !(verdict.ratio >= 0 && verdict.ratio <= 1))
489
+ return refuse('invalid', `verdicts[${index}].ratio is partial credit in [0, 1]`);
490
+ judged.push({ ...verdict, task: line.id, criterion: line.line });
491
+ }
492
+ const all = [...judged, ...auto];
493
+ const counted = all.filter(verdict => verdict.void !== true);
494
+ const ratio = counted.length === 0 ? 0 : counted.reduce((sum, verdict) => sum + (verdict.ratio ?? (verdict.pass ? 1 : 0)), 0) / counted.length;
495
+ return await this.mutate(project.id, () => {
496
+ const now = new Date();
497
+ const at = now.toISOString();
498
+ // No materialized assessment yet (a pre-v5 adopted path): mint it now,
499
+ // so the attempt keys an assessment like every other sitting.
500
+ let key = assessment;
501
+ if (key === undefined) {
502
+ key = { id: mintLibraryId('q'), type: 'practice', scope: { nodeId: node.id }, rubric, createdAt: at };
503
+ this.store.writeAssessment(project.id, key);
504
+ }
505
+ const attempt = { id: mintLibraryId('a'), assessmentId: key.id, verdicts: all, ratio, at };
506
+ this.store.appendAttempt(project.id, attempt);
507
+ // Cards: a failed acceptance line may leave a card (missedCard); a
508
+ // re-miss of a known card weakens it, exactly like a quiz miss.
509
+ const cards = this.store.readCards(project.id);
510
+ const byId = new Map(cards.map(card => [card.id, card]));
511
+ const order = cards.map(card => card.id);
512
+ const touched = new Map();
513
+ for (const verdict of counted) {
514
+ const passed = verdict.pass && (verdict.ratio ?? 1) >= 0.5;
515
+ if (passed || verdict.missedCard === undefined)
516
+ continue;
517
+ const term = verdict.missedCard.term.trim() || verdict.criterion.slice(0, 80);
518
+ const gloss = verdict.missedCard.gloss.trim() || verdict.criterion;
519
+ const existing = exactMatch([...byId.values()], [cardKey(term)]);
520
+ if (existing !== undefined) {
521
+ if (!live(existing))
522
+ continue;
523
+ const next = {
524
+ ...existing,
525
+ mastery: { box: 0, dueAt: leitnerDueAt(0, now), streak: 0 },
526
+ weakness: clampWeakness(existing.weakness + 1),
527
+ sources: [...existing.sources, { type: 'quiz', note: verdict.criterion.slice(0, 120), weakened: true, at }],
528
+ updatedAt: at,
529
+ };
530
+ byId.set(next.id, next);
531
+ touched.set(next.id, { id: next.id, term: next.term, weakness: next.weakness, mastery: next.mastery });
532
+ continue;
533
+ }
534
+ const card = {
535
+ id: mintLibraryId('c'),
536
+ kind: project.template === 'language' && !/\s/.test(term) ? 'word' : 'concept',
537
+ term,
538
+ aliases: [],
539
+ gloss,
540
+ contexts: [],
541
+ sources: [{ type: 'quiz', note: verdict.criterion.slice(0, 120), weakened: true, at }],
542
+ weakness: 1,
543
+ mastery: { box: 0, dueAt: leitnerDueAt(0, now), streak: 0 },
544
+ status: 'active',
545
+ createdAt: at,
546
+ updatedAt: at,
547
+ };
548
+ byId.set(card.id, card);
549
+ order.push(card.id);
550
+ touched.set(card.id, { id: card.id, term: card.term, weakness: card.weakness, mastery: card.mastery, created: true });
551
+ }
552
+ if (touched.size > 0)
553
+ this.store.writeCards(project.id, order.map(cardId => byId.get(cardId)));
554
+ // The station's completion and what it unlocks (acceptance carries no quizStats).
555
+ let unlocked = [];
556
+ let completed;
557
+ if (ratio >= passRatio && node.state === 'current') {
558
+ completed = node.id;
559
+ const marked = roadmap.nodes.map(candidate => candidate.id === node.id ? { ...candidate, state: 'done' } : candidate);
560
+ const result = unlockReady(marked);
561
+ unlocked = result.unlocked;
562
+ this.store.writeRoadmap(project.id, { ...roadmap, nodes: result.nodes });
563
+ }
564
+ const created = [...touched.values()].filter(update => update.created === true);
565
+ const onResource = resourceId === undefined ? {} : { resourceId };
566
+ this.store.appendEvents(project.id, [
567
+ { at, type: 'attempt.assessed', id: node.id, ...onResource, ratio },
568
+ ...(created.length === 0 ? [] : [{
569
+ at,
570
+ type: 'cards.recorded',
571
+ source: 'quiz',
572
+ title: created.map(update => update.term).join('、').slice(0, 300),
573
+ ids: created.map(update => update.id),
574
+ ...onResource,
575
+ count: created.length,
576
+ }]),
577
+ ...(completed === undefined ? [] : [{ at, type: 'node.completed', id: completed, title: titleOf(roadmap.nodes, completed), ...onResource, ratio }]),
578
+ ...unlocked.map(unlockedId => ({ at, type: 'node.unlocked', id: unlockedId, title: titleOf(roadmap.nodes, unlockedId), ...onResource })),
579
+ ]);
580
+ return {
581
+ ok: true,
582
+ attempt,
583
+ cardUpdates: [...touched.values()],
584
+ unlocked,
585
+ ...(completed === undefined ? {} : { completed }),
586
+ passRatio,
587
+ };
588
+ });
589
+ }
590
+ /** Every open miss across the project's adopted quiz stations, newest first. */
591
+ projectOpenMisses(projectId) {
592
+ const assessments = this.store.listAssessments(projectId);
593
+ const attempts = this.store.readAttempts(projectId);
594
+ const open = [];
595
+ for (const resource of this.store.listResources(projectId)) {
596
+ for (const node of this.store.readRoadmap(projectId, resource.id)?.nodes ?? []) {
597
+ if (node.gate === 'quiz')
598
+ open.push(...stationMastery(node, assessments, attempts).open);
599
+ }
600
+ }
601
+ return open.sort((a, b) => b.at.localeCompare(a.at));
602
+ }
603
+ /** A station's mastery as the ledger stands (undefined off a station, or for a node no adopted path holds). */
604
+ stationMasteryOf(projectId, nodeId, resourceId) {
605
+ if (nodeId === undefined)
606
+ return undefined;
607
+ const node = this.findNodeRoadmap(projectId, nodeId, resourceId)?.nodes.find(candidate => candidate.id === nodeId);
608
+ if (node === undefined)
609
+ return undefined;
610
+ return stationMastery(node, this.store.listAssessments(projectId), this.store.readAttempts(projectId));
611
+ }
612
+ /**
613
+ * The points a write moved, read against `before`; each change is ledgered
614
+ * as a `point.changed` event (今日 「最近的变化」). Call inside the lock,
615
+ * after the write.
616
+ */
617
+ pointChangesSince(projectId, nodeId, resourceId, before, at) {
618
+ if (nodeId === undefined || before === undefined)
619
+ return [];
620
+ const after = this.stationMasteryOf(projectId, nodeId, resourceId);
621
+ if (after === undefined)
622
+ return [];
623
+ const changes = masteryChanges(nodeId, before, after);
624
+ const roadmap = this.findNodeRoadmap(projectId, nodeId, resourceId);
625
+ const onResource = roadmap?.primaryResourceId === undefined ? {} : { resourceId: roadmap.primaryResourceId };
626
+ if (changes.length > 0) {
627
+ this.store.appendEvents(projectId, changes.map(change => {
628
+ const point = after.points.find(candidate => candidate.id === change.pointId);
629
+ const note = point?.notes.at(-1);
630
+ return {
631
+ at,
632
+ type: 'point.changed',
633
+ id: change.pointId,
634
+ nodeId,
635
+ title: change.title,
636
+ ...onResource,
637
+ from: change.from,
638
+ to: change.to,
639
+ count: point?.open.length ?? 0,
640
+ ...(change.to === 'weak' && note !== undefined && (point?.open.length ?? 0) === 0 ? { detail: (note.term ?? note.text).slice(0, 300) } : {}),
641
+ };
642
+ }));
643
+ }
644
+ return changes;
645
+ }
646
+ /**
647
+ * 订正: a retake paper re-asking a station's open misses (all, or the keys
648
+ * named — `${assessmentId}:${questionId}`). Its verdicts move the points,
649
+ * never the station's pass record. No coach needed: the questions exist.
650
+ */
651
+ async createRetake(request) {
652
+ this.assertAlive('createRetake');
653
+ const project = this.readProjectOrRefusal(request.projectId);
654
+ if ('error' in project)
655
+ return project;
656
+ return await this.mutate(project.value.id, () => {
657
+ // No station named: every station's open misses (今日复习 / the project's 错题).
658
+ let open;
659
+ if (request.nodeId === undefined) {
660
+ open = this.projectOpenMisses(project.value.id);
661
+ }
662
+ else {
663
+ const mastery = this.stationMasteryOf(project.value.id, request.nodeId, request.resourceId);
664
+ if (mastery === undefined)
665
+ return refuse('not-found', `no adopted station ${JSON.stringify(request.nodeId)}`);
666
+ open = [...mastery.open];
667
+ }
668
+ const wanted = request.keys === undefined || request.keys.length === 0 ? undefined : new Set(request.keys);
669
+ const misses = open.filter(miss => wanted === undefined || wanted.has(miss.key));
670
+ if (misses.length === 0)
671
+ return refuse('invalid', `nothing to retake here — ${request.nodeId === undefined ? 'the project' : 'this station'} has no open misses`);
672
+ const roadmap = request.nodeId === undefined ? undefined : this.findNodeRoadmap(project.value.id, request.nodeId, request.resourceId);
673
+ const assessment = {
674
+ id: mintLibraryId('q'),
675
+ type: 'retake',
676
+ scope: request.nodeId === undefined ? {} : { nodeId: request.nodeId, ...(roadmap?.primaryResourceId === undefined ? {} : { resourceId: roadmap.primaryResourceId }) },
677
+ questions: misses.map((miss, index) => ({ ...miss.question, id: `r${index + 1}`, origin: { assessmentId: miss.assessmentId, questionId: miss.questionId } })),
678
+ createdAt: new Date().toISOString(),
679
+ };
680
+ this.store.writeAssessment(project.value.id, assessment);
681
+ return { ok: true, assessment };
682
+ });
683
+ }
684
+ /**
685
+ * A coach note on a knowledge point: the learner stuck on it here. The
686
+ * point turns weak until a newer probe passes (subject projects — a weak
687
+ * point lands on its point, not on a card).
688
+ */
689
+ async notePoint(request) {
690
+ this.assertAlive('notePoint');
691
+ const project = this.readProjectOrRefusal(request.projectId);
692
+ if ('error' in project)
693
+ return project;
694
+ const text = request.text.trim();
695
+ if (text === '')
696
+ return refuse('invalid', 'a point note needs its text — what the learner stuck on, in one line');
697
+ return await this.mutate(project.value.id, () => {
698
+ const roadmap = this.findNodeRoadmap(project.value.id, request.nodeId, request.resourceId);
699
+ const node = roadmap?.nodes.find(candidate => candidate.id === request.nodeId);
700
+ if (roadmap === undefined || node === undefined)
701
+ return refuse('not-found', `no adopted station ${JSON.stringify(request.nodeId)} — library_read · roadmap lists them`);
702
+ const point = node.points?.find(candidate => candidate.id === request.pointId);
703
+ if (point === undefined) {
704
+ return refuse('not-found', `station ${node.title} has no point ${JSON.stringify(request.pointId)} — its points: ${(node.points ?? []).map(candidate => `${candidate.id} (${candidate.title})`).join(', ') || 'none'}`);
705
+ }
706
+ const before = this.stationMasteryOf(project.value.id, request.nodeId, request.resourceId);
707
+ const at = new Date().toISOString();
708
+ const note = { text: text.slice(0, 1000), ...(request.term === undefined || request.term.trim() === '' ? {} : { term: request.term.trim().slice(0, 100) }), at, ...(request.session === undefined ? {} : { session: request.session }) };
709
+ this.store.writeRoadmap(project.value.id, {
710
+ ...roadmap,
711
+ nodes: roadmap.nodes.map(candidate => candidate.id !== node.id ? candidate : {
712
+ ...candidate,
713
+ points: (candidate.points ?? []).map(entry => entry.id !== point.id ? entry : { ...entry, notes: [...(entry.notes ?? []), note] }),
714
+ }),
715
+ });
716
+ const changes = this.pointChangesSince(project.value.id, request.nodeId, request.resourceId, before, at);
717
+ const after = this.stationMasteryOf(project.value.id, request.nodeId, request.resourceId).points.find(candidate => candidate.id === point.id);
718
+ return { ok: true, point: after, changes };
719
+ });
720
+ }
721
+ /** The roadmap holding one node: the hinted textbook's first, then every adopted path of the project. */
722
+ findNodeRoadmap(projectId, nodeId, resourceId) {
723
+ const hinted = this.store.readRoadmap(projectId, resourceId);
724
+ if (hinted?.nodes.some(node => node.id === nodeId))
725
+ return hinted;
726
+ for (const resource of this.store.listResources(projectId)) {
727
+ const roadmap = this.store.readRoadmap(projectId, resource.id);
728
+ if (roadmap?.nodes.some(node => node.id === nodeId))
729
+ return roadmap;
730
+ }
731
+ return undefined;
732
+ }
733
+ /** The card a missed, card-less question leaves behind. */
734
+ missedCardFor(question, verdict, quiz, resourceId, template, at) {
735
+ const term = verdict.missedCard?.term.trim() ?? '';
736
+ const gloss = verdict.missedCard?.gloss.trim() || question.answer.trim();
737
+ return {
738
+ id: mintLibraryId('c'),
739
+ kind: template === 'language' && verdict.missedCard !== undefined && !/\s/.test(term) ? 'word' : 'concept',
740
+ term,
741
+ aliases: [],
742
+ gloss,
743
+ contexts: question.evidence.quote === undefined ? [] : [{ sentence: question.evidence.quote, ...(resourceId === undefined ? {} : { resourceId }), ...(question.evidence.pageId === undefined ? {} : { pageId: question.evidence.pageId }) }],
744
+ ...(resourceId === undefined ? {} : { resourceId }),
745
+ sources: [{ type: 'quiz', ref: quiz.id, note: verdict.criterion.slice(0, 120), weakened: true, at }],
746
+ weakness: 1,
747
+ mastery: { box: 0, dueAt: leitnerDueAt(0, new Date(at)), streak: 0 },
748
+ status: 'active',
749
+ createdAt: at,
750
+ updatedAt: at,
751
+ };
752
+ }
753
+ /**
754
+ * A project's graded sittings, newest first. `nodeId` narrows to one node's
755
+ * history: sittings keyed to an assessment scoped on it, plus legacy v4
756
+ * acceptance sittings that named the node directly. `assessmentId` narrows
757
+ * to one assessment's sittings.
758
+ */
759
+ async listAttempts(request) {
760
+ this.assertAlive('listAttempts');
761
+ let attempts = this.store.readAttempts(request.projectId);
762
+ if (request.assessmentId !== undefined)
763
+ attempts = attempts.filter(attempt => attempt.assessmentId === request.assessmentId);
764
+ if (request.nodeId !== undefined) {
765
+ const nodeAssessmentIds = new Set(this.store.listAssessments(request.projectId)
766
+ .filter(assessment => assessment.scope.nodeId === request.nodeId)
767
+ .map(assessment => assessment.id));
768
+ attempts = attempts.filter(attempt => attempt.nodeId === request.nodeId
769
+ || (attempt.assessmentId !== undefined && nodeAssessmentIds.has(attempt.assessmentId)));
770
+ }
771
+ return { attempts: attempts.reverse() };
772
+ }
773
+ }