@happyvertical/smrt-education 0.57.51 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -8
- package/README.md +36 -2
- package/agents/course-bundle-import.md +80 -2
- package/agents/course-work-design.md +144 -0
- package/agents/group-sharing.md +5 -1
- package/agents/learner-plan.md +3 -3
- package/agents/learner-service.md +1 -1
- package/agents/lesson-materials.md +52 -0
- package/agents/{workbook-service.md → lesson-work-service.md} +164 -95
- package/agents/{workbook-test-matrix.md → lesson-work-test-matrix.md} +3 -3
- package/agents/photo-intake.md +37 -50
- package/agents/question-records.md +1 -1
- package/agents/submission-service.md +22 -6
- package/dist/catalog-service.d.ts +1 -1
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog.d.ts +11 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/chunks/{workbook-photo-service-BtMQsbhq.js → workbook-photo-service-COtyQL1y.js} +5927 -4963
- package/dist/chunks/workbook-photo-service-COtyQL1y.js.map +1 -0
- package/dist/course-authoring-contracts.d.ts +5 -1
- package/dist/course-authoring-contracts.d.ts.map +1 -1
- package/dist/course-authoring-service.d.ts.map +1 -1
- package/dist/course-bundle-contracts.d.ts +23 -3
- package/dist/course-bundle-contracts.d.ts.map +1 -1
- package/dist/course-bundle-models.d.ts +3 -0
- package/dist/course-bundle-models.d.ts.map +1 -1
- package/dist/course-bundle-service.d.ts +45 -1
- package/dist/course-bundle-service.d.ts.map +1 -1
- package/dist/course-bundle.d.ts +32 -0
- package/dist/course-bundle.d.ts.map +1 -1
- package/dist/course-work-migration.d.ts +27 -0
- package/dist/course-work-migration.d.ts.map +1 -0
- package/dist/document-converter.d.ts +138 -0
- package/dist/document-converter.d.ts.map +1 -0
- package/dist/group-sharing-contracts.d.ts +3 -3
- package/dist/group-sharing-contracts.d.ts.map +1 -1
- package/dist/group-sharing-policy.d.ts +4 -4
- package/dist/group-sharing-policy.d.ts.map +1 -1
- package/dist/group-sharing-reader.d.ts.map +1 -1
- package/dist/index.d.ts +15 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +737 -49
- package/dist/index.js.map +1 -1
- package/dist/ingestion.d.ts +12 -11
- package/dist/ingestion.d.ts.map +1 -1
- package/dist/ingestion.js +63 -56
- package/dist/ingestion.js.map +1 -1
- package/dist/learner-plan-service.d.ts +1 -1
- package/dist/learner-plan-service.d.ts.map +1 -1
- package/dist/learner-service.d.ts +19 -5
- package/dist/learner-service.d.ts.map +1 -1
- package/dist/lesson-assets.d.ts +66 -2
- package/dist/lesson-assets.d.ts.map +1 -1
- package/dist/lesson-pages.d.ts +36 -0
- package/dist/lesson-pages.d.ts.map +1 -0
- package/dist/{workbook-completion.d.ts → lesson-work-completion.d.ts} +10 -10
- package/dist/lesson-work-completion.d.ts.map +1 -0
- package/dist/{workbook-contracts.d.ts → lesson-work-contracts.d.ts} +100 -52
- package/dist/lesson-work-contracts.d.ts.map +1 -0
- package/dist/lesson-work-models.d.ts +118 -0
- package/dist/lesson-work-models.d.ts.map +1 -0
- package/dist/lesson-work-service.d.ts +239 -0
- package/dist/lesson-work-service.d.ts.map +1 -0
- package/dist/manifest.json +6420 -5299
- package/dist/smrt-knowledge.json +2156 -1740
- package/dist/submission-service.d.ts +135 -5
- package/dist/submission-service.d.ts.map +1 -1
- package/dist/submissions.d.ts +2 -0
- package/dist/submissions.d.ts.map +1 -1
- package/dist/work-answer-regions.d.ts +11 -0
- package/dist/work-answer-regions.d.ts.map +1 -0
- package/dist/work-layout.d.ts +13 -0
- package/dist/work-layout.d.ts.map +1 -0
- package/dist/workbook-photo-contracts.d.ts +9 -7
- package/dist/workbook-photo-contracts.d.ts.map +1 -1
- package/dist/workbook-photo-ingestion.d.ts +1 -1
- package/dist/workbook-photo-ingestion.d.ts.map +1 -1
- package/dist/workbook-photo-models.d.ts +5 -5
- package/dist/workbook-photo-models.d.ts.map +1 -1
- package/dist/workbook-photo-service.d.ts +13 -10
- package/dist/workbook-photo-service.d.ts.map +1 -1
- package/package.json +8 -8
- package/dist/chunks/workbook-photo-service-BtMQsbhq.js.map +0 -1
- package/dist/workbook-answer-regions.d.ts +0 -11
- package/dist/workbook-answer-regions.d.ts.map +0 -1
- package/dist/workbook-completion.d.ts.map +0 -1
- package/dist/workbook-contracts.d.ts.map +0 -1
- package/dist/workbook-layout.d.ts +0 -13
- package/dist/workbook-layout.d.ts.map +0 -1
- package/dist/workbook-models.d.ts +0 -129
- package/dist/workbook-models.d.ts.map +0 -1
- package/dist/workbook-service.d.ts +0 -163
- package/dist/workbook-service.d.ts.map +0 -1
package/AGENTS.md
CHANGED
|
@@ -8,10 +8,12 @@ executor; live session/permission checks must use that executor. That read scope
|
|
|
8
8
|
collection's `list` to self, ancestors, and globals; it never authorizes a get,
|
|
9
9
|
write, raw query, sibling, or descendant read.
|
|
10
10
|
|
|
11
|
-
`personal-use-copy` courses and all `answer-key`
|
|
12
|
-
family material. Do not publish them, promote them, or expose them
|
|
13
|
-
learner-facing result. `LessonAsset`
|
|
14
|
-
do not add asset-id fields to lesson
|
|
11
|
+
`personal-use-copy` courses and all `answer-key`/`assessment-guidance` material
|
|
12
|
+
are private family material. Do not publish them, promote them, or expose them
|
|
13
|
+
through a learner-facing result. `LessonAsset` (lesson) and `CourseAsset` (course)
|
|
14
|
+
are the sole owner joins for material files; do not add asset-id fields to lesson
|
|
15
|
+
subclasses. Material audience rules and external videos: [lesson materials](agents/lesson-materials.md);
|
|
16
|
+
a learner never sees guardian-audience material.
|
|
15
17
|
|
|
16
18
|
Learner objects require exact tenant ownership and close generated surfaces.
|
|
17
19
|
`LearnerService` owns actor/resource authorization and transactional planning and
|
|
@@ -28,9 +30,12 @@ files after rollback; never expose source URIs or accept learner grade patches.
|
|
|
28
30
|
Validation: `pnpm --filter @happyvertical/smrt-education test`,
|
|
29
31
|
`test:postgres`, `typecheck`, and `build`.
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
Every lesson hand-in is lesson work (activity, attempt, revisions, release,
|
|
34
|
+
completion decision); books are courses with `bookKey`, pages are `LessonPage`s,
|
|
35
|
+
and loose work is placed by filing. Use [LessonWorkService](agents/lesson-work-service.md)
|
|
36
|
+
and the [design record](agents/course-work-design.md); `migrateWorkbooksToCourses`
|
|
37
|
+
is the one-time host migration. Keep provisional sharing separate from guardian
|
|
38
|
+
Grade authority; never move filed work's IDs. Photo-first receipt/context
|
|
34
39
|
capture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references
|
|
35
40
|
never create learning evidence, and blank exercises need explicit work intent.
|
|
36
41
|
|
|
@@ -52,7 +57,7 @@ Structured questions from scans and imported course files use
|
|
|
52
57
|
ingestion handler. Anchors come from provider region catalogs only; match by worksheet
|
|
53
58
|
id and layout, never text; ambiguous matches stay unattached; source-keyed replays.
|
|
54
59
|
|
|
55
|
-
Course bundles (zip with a closed manifest, or a file set) use
|
|
60
|
+
Course bundles (zip with a closed manifest, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed) use
|
|
56
61
|
[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the
|
|
57
62
|
bounded reader; refuse, never trim, hostile input; unsupported members stay
|
|
58
63
|
visible; proposals only, nothing auto-published.
|
package/README.md
CHANGED
|
@@ -36,13 +36,17 @@ Runtime catalog operations do not create application schema.
|
|
|
36
36
|
| Model | Purpose |
|
|
37
37
|
| --- | --- |
|
|
38
38
|
| `CourseSource` | Host-managed adapter policy and sharing ceiling |
|
|
39
|
-
| `Course` | Course metadata, license, status,
|
|
39
|
+
| `Course` | Course metadata, license, status, optional owning tenant, optional book identity (`bookKey`, `bookEdition`) and work policy |
|
|
40
40
|
| `ExternalCourse` | External course subtype sharing the Course table |
|
|
41
41
|
| `AuthoredCourse` | Authored course subtype sharing the Course table |
|
|
42
42
|
| `CourseSection` | Ordered section belonging to a course |
|
|
43
43
|
| `Lesson` | Ordered lesson belonging to a section |
|
|
44
44
|
| `Reading`, `Video`, `Worksheet`, `Exercise` | Lesson subtypes |
|
|
45
|
-
| `LessonAsset` | Lesson-owned file join with a
|
|
45
|
+
| `LessonAsset` | Lesson-owned file join with a role (teacher-guide, slides, learner-pages, assessment, assessment-guidance, handout, printable, answer-key, video, attachment) and an audience |
|
|
46
|
+
| `CourseAsset` | Course-level material (supply list, posters) with the same roles and audience |
|
|
47
|
+
| `LessonExternalVideo` | Allowlisted YouTube reference with title and step order; never downloaded |
|
|
48
|
+
| `LessonPage` | A lesson's printed page: key, label, order, optional source file and page |
|
|
49
|
+
| `LessonActivity`, `WorkAttempt`, `WorkAssessmentRevision`, `WorkFeedbackRelease`, `WorkFeedbackDelivery`, `WorkCompletionDecision`, `LessonWorkFiling` | The one lesson work pipeline (see below) |
|
|
46
50
|
| `Learner` | A required User/Person identity and guarded settings in one family tenant |
|
|
47
51
|
| `Curriculum` | A learner plan for a distinct period |
|
|
48
52
|
| `Enrollment` | One course in a curriculum |
|
|
@@ -162,6 +166,36 @@ guardian then confirms. It reuses learner ownership and transaction checks.
|
|
|
162
166
|
See the [submission integration contract](agents/submission-service.md) for
|
|
163
167
|
inputs, retry rules, signing requirements and external-storage compensation.
|
|
164
168
|
|
|
169
|
+
## Lesson work, books and materials
|
|
170
|
+
|
|
171
|
+
Every hand-in on every lesson is lesson work: `SubmissionService` capture and
|
|
172
|
+
correction record the learner's `LessonActivity` and an immutable
|
|
173
|
+
`WorkAttempt`, and `LessonWorkService` (formerly `WorkbookService`) owns the
|
|
174
|
+
timeline, revisioned AI and guardian marking, feedback release, delivery and
|
|
175
|
+
the parent completion decision. A guardian grade on an ordinary lesson
|
|
176
|
+
records its completion decision; book and loose-page work complete only by
|
|
177
|
+
`completeLessonActivity`. Submission pages carry a `pageKey`, so a correction
|
|
178
|
+
may replace individual pages (`WorkAttemptView.pages` folds the versions).
|
|
179
|
+
|
|
180
|
+
A physical book is a course: `Course.bookKey`/`bookEdition` (one course per
|
|
181
|
+
family per key) with the course work policy (`setCourseWorkPolicy`).
|
|
182
|
+
`Workbook` is retired; run `migrateWorkbooksToCourses(db)` once after the
|
|
183
|
+
schema migration. `resolveBookPageActivity` matches book key and page key to a
|
|
184
|
+
`LessonPage`; `fileLessonWork` places loose photographed work in a course.
|
|
185
|
+
|
|
186
|
+
Materials carry an audience defaulted by role; learners see only learner
|
|
187
|
+
material (`listLessonMaterials`, `listCourseMaterials`, `signLessonMaterial`,
|
|
188
|
+
`signCourseMaterial`, `setMaterialAudience`, `addLessonVideo`).
|
|
189
|
+
|
|
190
|
+
Renames: `WorkbookService` → `LessonWorkService`, `WorkbookActivity` →
|
|
191
|
+
`LessonActivity`, `Workbook{Attempt,AssessmentRevision,FeedbackRelease,FeedbackDelivery,CompletionDecision}`
|
|
192
|
+
→ `Work…`, `Workbook…` contracts → `Work…` (`WORK_MATH_VOCABULARY`), and the
|
|
193
|
+
methods listed in the [design record](agents/course-work-design.md#renames-public-api).
|
|
194
|
+
Removed: `Workbook`, `WorkbookWorkFiling`, `WorkbookWorkflowRequiredError`
|
|
195
|
+
(now `LessonWorkWorkflowRequiredError`) and `assertNonWorkbookSubmissionScope`
|
|
196
|
+
(now `assertOrdinarySubmissionScope`). See [lesson work](agents/lesson-work-service.md)
|
|
197
|
+
and [lesson materials](agents/lesson-materials.md).
|
|
198
|
+
|
|
165
199
|
## Source imports
|
|
166
200
|
|
|
167
201
|
`SourceImportService` imports Easy Peasy pages as private family pointers after
|
|
@@ -38,7 +38,8 @@ authorizer: `importBundle` needs `education.course.author`, `readImport`
|
|
|
38
38
|
4. `readImport({ ...actor, importId })` returns the recorded evidence.
|
|
39
39
|
|
|
40
40
|
Progress is an observer callback (`onProgress({ stage, completed, total })`,
|
|
41
|
-
stages `unpack`, `classify`, `extract`, `propose`; each starts at `completed: 0
|
|
41
|
+
stages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;
|
|
42
|
+
`render` is emitted only when the bundle has Word/PowerPoint members).
|
|
42
43
|
Ingestion has no public job-progress seam, so none is invented. A throwing
|
|
43
44
|
listener never affects the import.
|
|
44
45
|
|
|
@@ -55,6 +56,9 @@ listener never affects the import.
|
|
|
55
56
|
| `maxManifestBytes` | 256 KiB |
|
|
56
57
|
| `maxLessons` (the proposal schema's cap) | 50 |
|
|
57
58
|
| `maxLessonBodyChars` | 100 000 |
|
|
59
|
+
| `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |
|
|
60
|
+
| `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |
|
|
61
|
+
| `conversionTimeoutMs` (one Office member) | 60 000 |
|
|
58
62
|
| archive nesting | 1: a zip inside a zip is never expanded |
|
|
59
63
|
|
|
60
64
|
A lesson whose combined text members exceed `maxLessonBodyChars` refuses the
|
|
@@ -80,7 +84,7 @@ too long or deep). An **honestly** oversize member (declared over
|
|
|
80
84
|
|
|
81
85
|
## Members and statuses
|
|
82
86
|
|
|
83
|
-
By content (never by extension alone): `pdf` (`%PDF-` in the first 1 KiB),
|
|
87
|
+
By content (never by extension alone): `docx`/`pptx` (see below), `pdf` (`%PDF-` in the first 1 KiB),
|
|
84
88
|
`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control
|
|
85
89
|
characters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.
|
|
86
90
|
Status: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier
|
|
@@ -91,6 +95,74 @@ extracted again), `unsupported` (`unsupported_type`, `nested_archive`,
|
|
|
91
95
|
`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive
|
|
92
96
|
on literal text inside an uncompressed stream is refused, never read.
|
|
93
97
|
|
|
98
|
+
## Word and PowerPoint members (SMRT #4615)
|
|
99
|
+
|
|
100
|
+
A `.docx`/`.pptx` member is an OOXML package only when the name and the content
|
|
101
|
+
agree: a zip whose entries include `[Content_Types].xml` and `word/document.xml`
|
|
102
|
+
(docx) or `ppt/presentation.xml` (pptx). Before the bytes go anywhere, the
|
|
103
|
+
package itself passes the same bounded zip reader (ratio, overlap, paths, CRC,
|
|
104
|
+
100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the
|
|
105
|
+
whole bundle. Other zips stay `unsupported / nested_archive`.
|
|
106
|
+
|
|
107
|
+
Two separate things happen to an accepted Office member, both after
|
|
108
|
+
authorization and before the evidence is written:
|
|
109
|
+
|
|
110
|
+
- **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original
|
|
111
|
+
to PDF. The importer enforces the byte and time limits itself (input
|
|
112
|
+
`maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort
|
|
113
|
+
signal and a hard race, so an adapter that ignores the signal still times out),
|
|
114
|
+
and rejects output that is not a PDF or is encrypted. The converter is told a
|
|
115
|
+
neutral file name (`document.docx`), never the bundle path. Identical documents
|
|
116
|
+
are rendered once.
|
|
117
|
+
- **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`
|
|
118
|
+
costs the member nothing) returns whole-document text and, for decks, text and
|
|
119
|
+
speaker notes per slide. Office text is handed to the host on the accepted
|
|
120
|
+
member (`officeText`) and is **never** inlined into a proposed lesson body:
|
|
121
|
+
teacher guides and notes carry answers, and audience belongs to manifest v2.
|
|
122
|
+
|
|
123
|
+
`receiveMember` gets the original `bytes` (store them as the private original
|
|
124
|
+
asset: "Open in PowerPoint" for decks) and `rendered` (`{ bytes, sha256, mediaType:
|
|
125
|
+
'application/pdf', converterId }`, store as a private asset beside the original:
|
|
126
|
+
the pages for "Present"/reading). The member row records `renderedSha256` and
|
|
127
|
+
`renderedSizeBytes`; bytes are still not retained here.
|
|
128
|
+
|
|
129
|
+
**Fail closed.** Any converter problem records that member `unsupported` with a
|
|
130
|
+
reason and writes the rest of the bundle normally: `converter_unavailable` (none
|
|
131
|
+
configured), `converter_failed` (unreachable or 5xx), `converter_timeout`,
|
|
132
|
+
`converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),
|
|
133
|
+
`office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that
|
|
134
|
+
failed is not a lesson in the proposal, and identical copies of a failed document
|
|
135
|
+
fail the same way rather than becoming duplicates of nothing. Outcomes are part
|
|
136
|
+
of the append-only evidence, so a replay does not retry a recorded failure (to
|
|
137
|
+
retry, change the bundle). A replay does re-render recorded-accepted members so the
|
|
138
|
+
host receives the bytes again; if it can no longer render one it throws
|
|
139
|
+
`DocumentConversionError` rather than silently dropping an accepted member.
|
|
140
|
+
|
|
141
|
+
### Host configuration
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const service = new CourseBundleImportService(db, authorizer, {
|
|
145
|
+
converter: new GotenbergDocumentConverter({
|
|
146
|
+
baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000
|
|
147
|
+
timeoutMs: 60_000,
|
|
148
|
+
maxBytes: 50 * 1024 * 1024,
|
|
149
|
+
}),
|
|
150
|
+
officeTextReader, // optional, see below
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks
|
|
155
|
+
to nothing else, refuses redirects and URLs with credentials, and caps the response
|
|
156
|
+
as it streams. Run Gotenberg as an internal-only service with **no outbound
|
|
157
|
+
network**, its own size/time limits and no shared volume with the app; this
|
|
158
|
+
package assumes untrusted files never run next to the application. Tests use
|
|
159
|
+
`FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).
|
|
160
|
+
|
|
161
|
+
`OfficeTextReader` is deliberately not wired to the SDK here, so this package
|
|
162
|
+
does not depend on an unreleased `@happyvertical/documents`. Once the Office
|
|
163
|
+
processors ship (sdk#1485, `extractOffice(bytes, 'docx' | 'pptx')`), implement
|
|
164
|
+
`read` with it; the snippet is in the `OfficeTextReader` doc comment.
|
|
165
|
+
|
|
94
166
|
## Manifest (`manifest.json`, version 1, closed)
|
|
95
167
|
|
|
96
168
|
```json
|
|
@@ -139,6 +211,12 @@ Normal manifest migrations create both tables.
|
|
|
139
211
|
|
|
140
212
|
## Not in this release
|
|
141
213
|
|
|
214
|
+
- Manifest version 2 (material roles with audience, lesson pages, course-level
|
|
215
|
+
members, external video references) waits for the phase 1 role vocabulary
|
|
216
|
+
(smrt#4614). The page-role vocabulary and the closed v1 schema are unchanged;
|
|
217
|
+
see the hand-off note on smrt#4615.
|
|
218
|
+
- Re-rendering a member whose first import recorded a converter failure (outcomes
|
|
219
|
+
are append-only per bundle identity).
|
|
142
220
|
- Ingestion's receipt contract is unchanged: there is no `application/zip` part
|
|
143
221
|
type or `parentEvidenceId` in ingestion. The education side unpacks and the
|
|
144
222
|
host receives members one by one, so archive-to-member lineage lives in
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Course work design (SMRT #4614, folds in #4613)
|
|
2
|
+
|
|
3
|
+
Phase 1 of hearthed.org#125. Breaking changes are allowed: Hearth is the only
|
|
4
|
+
consumer. This note is the design record; the current contracts live in
|
|
5
|
+
[lesson work](lesson-work-service.md), [photo intake](photo-intake.md),
|
|
6
|
+
[submission](submission-service.md) and [lesson materials](lesson-materials.md).
|
|
7
|
+
|
|
8
|
+
## Model
|
|
9
|
+
|
|
10
|
+
- **Course absorbs Workbook.** `Course` gains optional book identity: `bookKey`
|
|
11
|
+
(`''` = not a book), `bookEdition`, and the course work policy
|
|
12
|
+
`workReviewRequired` (default true) / `workPolicyVersion` (default 1). A
|
|
13
|
+
book key requires an owning tenant and is unique per family (one course per
|
|
14
|
+
tenant per book key, enforced at save). `Workbook` and its learner-scoped
|
|
15
|
+
rows are retired; the model is deleted.
|
|
16
|
+
- **Lessons have pages.** `LessonPage` (tenant, lesson, `pageKey`, `label`,
|
|
17
|
+
`sortOrder`, nullable `sourceAssetId` + `sourcePageNumber`) is family-scoped
|
|
18
|
+
course structure, unique per (tenant, lesson, page key). Photo identification
|
|
19
|
+
resolves book key → course, page key → lesson page; a book page without a
|
|
20
|
+
lesson gets a one-page Worksheet lesson in the book course.
|
|
21
|
+
- **One work pipeline.** Every hand-in on every lesson is a `LessonActivity`
|
|
22
|
+
(one per learner enrollment and lesson) with immutable `WorkAttempt`s,
|
|
23
|
+
revisioned `WorkAssessmentRevision`s, `WorkFeedbackRelease`s,
|
|
24
|
+
`WorkFeedbackDelivery`s and `WorkCompletionDecision`s. `SubmissionService`
|
|
25
|
+
capture and correction write the activity and attempt in the same
|
|
26
|
+
transaction, so ordinary lessons, book pages and loose photos share one
|
|
27
|
+
timeline, review, release and completion contract. Completion differs by
|
|
28
|
+
kind: on an ordinary lesson the guardian grade records the completion
|
|
29
|
+
decision on the graded attempt (and the learner sees the grade); book and
|
|
30
|
+
loose-page work is release-gated and completes only by the parent decision,
|
|
31
|
+
so it stays on `LessonWorkService` (generic capture refuses it with
|
|
32
|
+
`LessonWorkWorkflowRequiredError`).
|
|
33
|
+
- **Per-page versions (#4613).** `SubmissionAsset.pageKey` names the lesson page
|
|
34
|
+
a file belongs to (default `p<n>` by position). A correction may carry only
|
|
35
|
+
the pages it replaces; earlier pages stay at their version. Attempt views
|
|
36
|
+
expose `pages: [{pageKey, versions:[{submissionId, attemptId, assetId}]}]`
|
|
37
|
+
folded over the correction chain. Grades stay bound to their submission.
|
|
38
|
+
- **Filing places work.** `LessonWorkFiling` (new table) appends a placement for
|
|
39
|
+
unfiled work: target course, the learner's enrollment, lesson and lesson page.
|
|
40
|
+
A book course appends a Worksheet lesson when no lesson has the page key; an
|
|
41
|
+
authored course needs the lesson to exist (the host appends it first with
|
|
42
|
+
`CourseAuthoringService.appendLesson`, which owns revisions and releases).
|
|
43
|
+
Placement is the activity's effective location: lists by course include it,
|
|
44
|
+
the unfiled list drops it, and the target lesson's progress follows the
|
|
45
|
+
activity's completion. Activity, attempt, submission and assessment IDs never
|
|
46
|
+
move. Replays are no-ops; a changed replay or stale head conflicts.
|
|
47
|
+
- **Material roles and audience.** `LessonAssetRelationship` is `teacher-guide`,
|
|
48
|
+
`slides`, `learner-pages`, `assessment`, `assessment-guidance`, `handout`,
|
|
49
|
+
`printable`, `answer-key`, `video`, `attachment`. Each `LessonAsset` carries
|
|
50
|
+
`audience: 'learner' | 'guardian'` with a per-role default
|
|
51
|
+
(`LESSON_ASSET_DEFAULT_AUDIENCE`): guardian for teacher-guide, slides,
|
|
52
|
+
assessment-guidance, answer-key; learner otherwise. `answer-key` and
|
|
53
|
+
`assessment-guidance` are always guardian. `CourseAsset` holds course-level
|
|
54
|
+
materials with the same roles and audience.
|
|
55
|
+
- **External videos.** A `video` material may be an external reference instead
|
|
56
|
+
of a stored asset: `externalProvider: 'youtube'`, an 11-character video id
|
|
57
|
+
(from an id or an allowlisted youtube.com / youtu.be URL), a title and a step
|
|
58
|
+
order. It is never downloaded; no Asset row exists for it.
|
|
59
|
+
|
|
60
|
+
## Audience rule
|
|
61
|
+
|
|
62
|
+
Learner-facing reads (`listLessonMaterials`/`listCourseMaterials` for the learner
|
|
63
|
+
themself, `signLessonMaterial`/`signCourseMaterial`, and every catalog projection
|
|
64
|
+
without answer keys) return only rows whose effective audience is `learner`.
|
|
65
|
+
Guardian-only material, keys, rubrics, notes and storage URIs never reach a
|
|
66
|
+
learner. A guardian may change a family-owned row's audience within the role's
|
|
67
|
+
bounds through `setMaterialAudience`; authored courses change it through course
|
|
68
|
+
authoring (`CourseMaterialInput.audience`) and the next release.
|
|
69
|
+
|
|
70
|
+
## Renames (public API)
|
|
71
|
+
|
|
72
|
+
Storage names that are persisted identifiers keep their value: tables
|
|
73
|
+
`workbook_activities`, `workbook_attempts`, `workbook_assessment_revisions`,
|
|
74
|
+
`workbook_feedback_releases`, `workbook_feedback_deliveries`,
|
|
75
|
+
`workbook_completion_decisions`, `workbook_photo_*`, and the ingestion
|
|
76
|
+
handler/operation IDs captured in receipt ceilings. Renaming them would copy
|
|
77
|
+
append-only history and re-point group-sharing foreign keys for no behaviour
|
|
78
|
+
change. The photo-intake models (`WorkbookPhoto*`) keep their class names for
|
|
79
|
+
the same reason (their names derive persisted operation IDs and grants).
|
|
80
|
+
|
|
81
|
+
| Before | After |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `WorkbookService` | `LessonWorkService` |
|
|
84
|
+
| `WorkbookActivity` / `WorkbookActivityView` | `LessonActivity` / `LessonActivityView` |
|
|
85
|
+
| `WorkbookAttempt`, `WorkbookAssessmentRevision`, `WorkbookFeedbackRelease`, `WorkbookFeedbackDelivery`, `WorkbookCompletionDecision` | `WorkAttempt`, `WorkAssessmentRevision`, `WorkFeedbackRelease`, `WorkFeedbackDelivery`, `WorkCompletionDecision` |
|
|
86
|
+
| `WorkbookAssessment`, `WorkbookQuestion`, … contracts, `WORKBOOK_MATH_VOCABULARY` | `WorkAssessment`, `WorkQuestion`, …, `WORK_MATH_VOCABULARY` |
|
|
87
|
+
| `resolveWorkbookActivity({workbookKey,title,edition,pageKey,pageLabel})` | `resolveBookPageActivity({bookKey,title,edition,pageKey,pageLabel})` |
|
|
88
|
+
| `getWorkbookActivity`, `listWorkbookActivities` | `getLessonActivity`, `listLessonActivities({courseId?, filing?})` |
|
|
89
|
+
| `setWorkbookFeedbackPolicy({activityId,…})` | `setCourseWorkPolicy({courseId,expectedPolicyVersion,reviewRequired})` |
|
|
90
|
+
| `captureWorkbookWork` | `captureLessonWork` (pages may carry `pageKey`) |
|
|
91
|
+
| `recordWorkbookAnalysis`, `reviewWorkbookAssessment`, `releaseWorkbookFeedback`, `acknowledgeWorkbookFeedbackDelivery`, `completeWorkbookActivity`, `getWorkbookTimeline` | `recordWorkAnalysis`, `reviewWorkAssessment`, `releaseWorkFeedback`, `acknowledgeWorkFeedbackDelivery`, `completeLessonActivity`, `getWorkTimeline` |
|
|
92
|
+
| `getWorkbook{AttemptBySubmission,PreservationState,SourceAnchors,QuestionIdentity,QuestionContext}` | `getWork…` |
|
|
93
|
+
| `fileWorkbookWork({workbookId \| newWorkbook, canonicalActivityId})` | `fileLessonWork({courseId \| newBook, lessonId?, pageKey?, pageLabel?})` |
|
|
94
|
+
| `listWorkbookPhotoWorkbooks` | `listPhotoBookCourses` |
|
|
95
|
+
| `WorkbookContextRevision` (`workbookId`) | `BookContextRevision` (`courseId`), table `book_context_revisions` |
|
|
96
|
+
| `WorkbookWorkflowRequiredError`, `assertNonWorkbookSubmissionScope` | `LessonWorkWorkflowRequiredError`, `assertOrdinarySubmissionScope` (book and loose-page work only) |
|
|
97
|
+
| `WorkbookPhotoIntakeView.workbookId` | `bookCourseId` |
|
|
98
|
+
| `Workbook`, `WorkbookWorkFiling` | removed (`Course` book identity, `LessonWorkFiling`) |
|
|
99
|
+
|
|
100
|
+
DTO fields: `workbookKey`/`edition`/`title` on activity views become
|
|
101
|
+
`courseId`, `courseTitle`, `bookKey`, `bookEdition`, `lessonTitle` and `pages`
|
|
102
|
+
(the effective placement; `enrollmentId`/`lessonId` stay the capture routing);
|
|
103
|
+
`filing.workbookId`/`canonicalActivityId` become `filing.placement`. Submission
|
|
104
|
+
pages and `WorkAttemptView` gain `pageKey`/`pages`.
|
|
105
|
+
|
|
106
|
+
## Migration
|
|
107
|
+
|
|
108
|
+
`migrateWorkbooksToCourses(db)` (exported, host-run after the schema migration,
|
|
109
|
+
idempotent, transactional). It reads the retired `workbooks`,
|
|
110
|
+
`workbook_work_filings` and `workbook_context_revisions` tables when they exist
|
|
111
|
+
and is a no-op otherwise. It first drops the retired unique index
|
|
112
|
+
`workbook_activities_tenant_id_workbook_id_idx` (tenant, workbook, page key): new
|
|
113
|
+
activities carry no book link and PostgreSQL's NULLS NOT DISTINCT would make them
|
|
114
|
+
collide. The schema diff leaves the retired `workbook_id`/`page_key`/`page_label`
|
|
115
|
+
columns as reported orphans; drop them later with `--drop-columns`.
|
|
116
|
+
The comparer's "rename data pending" note for `workbook_activities.context`
|
|
117
|
+
(it guesses the orphan `page_key`/`page_label` data was renamed) is report-only
|
|
118
|
+
and must not be acted on. `LessonActivity` declares `onDelete: 'NO ACTION'` on
|
|
119
|
+
`enrollmentId`/`lessonId`: as its new natural key they would otherwise default to
|
|
120
|
+
CASCADE and differ from the deployed foreign keys, which SQLite can only change
|
|
121
|
+
by a table rebuild (`lesson-work-schema-contract.test.ts`). The opt-in
|
|
122
|
+
`course-work-real-data.optional.test.ts` (`HEARTH_LEGACY_DB=<copy>`) runs the
|
|
123
|
+
schema step, the data step and new hand-ins on a copy of a real database.
|
|
124
|
+
|
|
125
|
+
1. Group workbooks by (tenant, workbook key). The canonical course is the
|
|
126
|
+
earliest (created_at, id) learner's `physical-workbook` scaffold course; it
|
|
127
|
+
receives `bookKey`, `bookEdition`, title and the strictest policy (review
|
|
128
|
+
required if any duplicate required it; policy version = max).
|
|
129
|
+
2. Each other learner's scaffold course is merged: its sections and its
|
|
130
|
+
enrollment are re-pointed to the canonical course, and the emptied course is
|
|
131
|
+
archived with `sourceKey: 'merged-book'`, `sourceRef: <canonical id>`.
|
|
132
|
+
Learner history rows (progress, submissions, attempts, grades) are not
|
|
133
|
+
rewritten; their lessons move with their section.
|
|
134
|
+
3. Every legacy activity's page key/label becomes a `LessonPage` on its lesson.
|
|
135
|
+
4. Legacy filings become `LessonWorkFiling` rows with the same IDs, predecessors
|
|
136
|
+
first, placing the work in the canonical course: on the named canonical
|
|
137
|
+
activity's lesson and page, else on a new `Captured work` lesson. The target
|
|
138
|
+
lesson's progress row is created from the work's completion.
|
|
139
|
+
5. Context revisions are copied into `book_context_revisions` with the same IDs
|
|
140
|
+
and `courseId` = canonical course.
|
|
141
|
+
|
|
142
|
+
Each step skips rows already migrated (same IDs or existing book key), so a
|
|
143
|
+
re-run changes nothing. Retired tables are left in place for audit; dropping
|
|
144
|
+
them is a later deliberate operation.
|
package/agents/group-sharing.md
CHANGED
|
@@ -104,7 +104,7 @@ The authorization callback is `GroupSharingAuthorizer(request)` where request ca
|
|
|
104
104
|
|
|
105
105
|
## Owning workbook/media seams
|
|
106
106
|
|
|
107
|
-
Current `
|
|
107
|
+
Current `LessonWorkService` activity/timeline APIs require learner/guardian authority; replacing the authorizer with unconditional true would create a broad guardian backdoor. Add an explicit read-only owning projection seam with a required exact-resource authorization callback, invoked on its active executor before loading private data and again at final projection/delivery where appropriate. It must not expose mutation methods or accept an externally cached authorization boolean. This seam can live in a dedicated education sharing reader that uses owned collections with exact predicates; private workbook algorithms should be shared through an owning helper rather than copied or private-state access.
|
|
108
108
|
|
|
109
109
|
The selected attempt must belong to exact family/learner/activity/submission; selected revision must belong to that attempt. Original bytes must be owned through `SubmissionAsset`, match the retained source ID/hash and belong to the learner's private asset owner. Photo covers/reference-only receipts are not work. The ordinary `signSubmissionAsset` returns a signed URL after learner authorization; it is insufficient for this sharing contract. `readSharedMedia` must run current policy/role/session/edge checks for every HTTP delivery and open the exact `withPrivateAssetAccess` scope only within that request. Return bytes or execute a scoped streaming callback; never return public/presigned storage URLs or a long-lived capability. The host route must invoke it even for a replayed/direct URL and configure private storage/cache headers. No framework can revoke bytes already delivered; successful reads linearize at their final authority check. A revocation completed before a new request must always deny that request.
|
|
110
110
|
|
|
@@ -296,3 +296,7 @@ label only; no reviewed cross-tenant read path exists for it, so it grants nothi
|
|
|
296
296
|
Non-authored courses, answer keys and personal-use rules are untouched, and a
|
|
297
297
|
database without the visibility column or release tables reads as never recorded.
|
|
298
298
|
The gate is `authoredVisibilityPermitsGroups(db, tenantId, releaseCourseId)`.
|
|
299
|
+
|
|
300
|
+
Filed loose work is attributed to its current filing placement (target lesson,
|
|
301
|
+
enrollment and course, hence subject) under the same consent, offered-metadata and
|
|
302
|
+
pin-lineage rules as routed work; unplaced loose work stays unknown metadata.
|
package/agents/learner-plan.md
CHANGED
|
@@ -19,16 +19,16 @@ Technical unfiled capture scaffolding has sourceKey exactly `unfiled-work`;
|
|
|
19
19
|
consumers exclude it by identity, never by title. The DTO does not invent a cover
|
|
20
20
|
or claim any source/asset preview binding. It returns no teacher or answer-key
|
|
21
21
|
assets. Lessons preserve catalog section/lesson order and existing role-filtered
|
|
22
|
-
progress, including private workbook feedback rules. Optional
|
|
22
|
+
progress, including private workbook feedback rules. Optional lessonActivityId
|
|
23
23
|
is resolved for the exact tenant/learner/enrollment/lesson; consumers route that
|
|
24
24
|
work to their workbook interface, not generic scan capture.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Book and loose-page progress uses the same explicit parent completion projection as Work.
|
|
27
27
|
Old grade-derived complete/skipped rows without a current decision project in
|
|
28
28
|
progress with completedAt null; they cannot contribute a completion timestamp to
|
|
29
29
|
progressEvidence. Later analysis of the same attempt retains a parent's decision;
|
|
30
30
|
a new correction or practice attempt reopens it. Ordinary lesson progress is
|
|
31
|
-
unchanged. See [the workbook contract](
|
|
31
|
+
unchanged. See [the workbook contract](lesson-work-service.md).
|
|
32
32
|
|
|
33
33
|
For required-online lessons, optional sourceUrl comes only from the same
|
|
34
34
|
catalog-authorized Lesson.externalUrl and must be HTTPS without credentials.
|
|
@@ -115,7 +115,7 @@ Same-tenant membership alone never grants sibling access.
|
|
|
115
115
|
returns `{ changed: false }`; one complete any other way (guardian
|
|
116
116
|
`attestLesson`/grade, workbook completion) throws `LessonAlreadyCompleteError`.
|
|
117
117
|
Also `EnrollmentNotActiveError` (`.status`; only `active` enrollments),
|
|
118
|
-
`LessonSkippedError`, `
|
|
118
|
+
`LessonSkippedError`, `LessonWorkOwnedError` (a workbook lesson completes
|
|
119
119
|
through its workbook decision), and `LearnerHistoryUnavailableError` without
|
|
120
120
|
`lesson_completion_records`. Guardian `attestLesson` is unchanged.
|
|
121
121
|
- `setEnrollmentPace({ enrollmentId, pace: { lessonsPerWeek } | null, policyBasis })`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Lesson materials and audience
|
|
2
|
+
|
|
3
|
+
Design record: [course work](course-work-design.md). Models are closed (no REST,
|
|
4
|
+
CLI, MCP or AI surface); reads and writes go through `SubmissionService` (and its
|
|
5
|
+
`LessonWorkService` subclass) with the usual actor, learner and enrollment checks.
|
|
6
|
+
|
|
7
|
+
## Roles and audience
|
|
8
|
+
|
|
9
|
+
`LessonAssetRelationship` (`LESSON_ASSET_RELATIONSHIPS`): `teacher-guide`, `slides`,
|
|
10
|
+
`learner-pages`, `assessment`, `assessment-guidance`, `handout`, `printable`,
|
|
11
|
+
`answer-key`, `video`, `attachment`. Each `LessonAsset`, `CourseAsset` and
|
|
12
|
+
`LessonExternalVideo` row has `audience: 'learner' | 'guardian'`; `''` means the role
|
|
13
|
+
default (`LESSON_ASSET_DEFAULT_AUDIENCE`): guardian for teacher-guide, slides,
|
|
14
|
+
assessment-guidance and answer-key, learner otherwise. `materialAudience(role,
|
|
15
|
+
audience?)` resolves and validates it. `answer-key` and `assessment-guidance`
|
|
16
|
+
(`GUARDIAN_ONLY_RELATIONSHIPS`) can never be learner-audience and, like any join in
|
|
17
|
+
a personal-use-copy course, require a private asset under the host's exact
|
|
18
|
+
actor/owner/asset capability.
|
|
19
|
+
|
|
20
|
+
`CourseAsset` holds course-level material (supply list, posters) with the same
|
|
21
|
+
roles, audience and private-asset rule, plus an optional title.
|
|
22
|
+
|
|
23
|
+
## Learner never sees guardian material
|
|
24
|
+
|
|
25
|
+
`listLessonMaterials({enrollmentId, lessonId})` and `listCourseMaterials({enrollmentId})`
|
|
26
|
+
return `LessonMaterialView`s (`id`, `kind` file or external-video, `scope`, role,
|
|
27
|
+
audience, title, order, `assetId` or provider/videoId/`embedUrl`). The learner
|
|
28
|
+
themself sees only learner-audience rows; an authorized guardian sees all. The
|
|
29
|
+
catalog projection without answer keys (every learner-facing catalog read) applies
|
|
30
|
+
the same rule. `signLessonMaterial`/`signCourseMaterial` sign only a file in the
|
|
31
|
+
actor's own list (claim scope `lesson:<id>` / `course:<id>`); a guessed id of
|
|
32
|
+
guardian material is denied. `signLessonPrintable` applies the same audience
|
|
33
|
+
rule to the learner. Storage URIs never appear.
|
|
34
|
+
|
|
35
|
+
`setMaterialAudience({enrollmentId, scope, kind, materialId, audience})` lets a
|
|
36
|
+
guardian with plan authority change a family-owned row within the role's bounds.
|
|
37
|
+
Authored courses change audience through course authoring (`CourseMaterialInput.audience`,
|
|
38
|
+
carried to the released join); shared or other families' rows are denied.
|
|
39
|
+
|
|
40
|
+
## External videos
|
|
41
|
+
|
|
42
|
+
`LessonExternalVideo` references a video at an allowlisted provider (YouTube only):
|
|
43
|
+
`videoId` (11 characters), title, `stepOrder` and audience. It is never downloaded
|
|
44
|
+
and has no Asset row. `parseYouTubeReference` accepts a bare id or an https
|
|
45
|
+
youtube.com / m. / music. / youtu.be / youtube-nocookie.com URL (watch, embed,
|
|
46
|
+
shorts, live) and nothing else; `externalVideoEmbedUrl` returns the
|
|
47
|
+
youtube-nocookie embed URL. `addLessonVideo({enrollmentId, lessonId, reference,
|
|
48
|
+
title, stepOrder, audience?})` adds one to a family-owned lesson (plan authority);
|
|
49
|
+
the same video with the same details replays, other details conflict, and a
|
|
50
|
+
non-allowlisted reference throws `ExternalVideoReferenceError`.
|
|
51
|
+
|
|
52
|
+
Tests: `src/__tests__/lesson-materials.test.ts`.
|