@happyvertical/smrt-education 0.57.48 → 0.57.49

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 (57) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +23 -2
  3. package/agents/course-authoring-service.md +44 -1
  4. package/agents/course-bundle-import.md +148 -0
  5. package/agents/group-sharing.md +20 -0
  6. package/agents/learner-service.md +35 -0
  7. package/agents/question-extraction.md +4 -1
  8. package/agents/tutor-context.md +62 -0
  9. package/dist/catalog.d.ts +12 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/chunks/{workbook-photo-service-B7Tujv__.js → workbook-photo-service-WwDDC5GF.js} +692 -18
  12. package/dist/chunks/workbook-photo-service-WwDDC5GF.js.map +1 -0
  13. package/dist/course-authoring-contracts.d.ts +26 -3
  14. package/dist/course-authoring-contracts.d.ts.map +1 -1
  15. package/dist/course-authoring-models.d.ts +44 -1
  16. package/dist/course-authoring-models.d.ts.map +1 -1
  17. package/dist/course-authoring-service.d.ts +39 -0
  18. package/dist/course-authoring-service.d.ts.map +1 -1
  19. package/dist/course-bundle-contracts.d.ts +93 -0
  20. package/dist/course-bundle-contracts.d.ts.map +1 -0
  21. package/dist/course-bundle-models.d.ts +66 -0
  22. package/dist/course-bundle-models.d.ts.map +1 -0
  23. package/dist/course-bundle-service.d.ts +126 -0
  24. package/dist/course-bundle-service.d.ts.map +1 -0
  25. package/dist/course-bundle-zip.d.ts +48 -0
  26. package/dist/course-bundle-zip.d.ts.map +1 -0
  27. package/dist/course-bundle.d.ts +135 -0
  28. package/dist/course-bundle.d.ts.map +1 -0
  29. package/dist/course-visibility.d.ts +22 -0
  30. package/dist/course-visibility.d.ts.map +1 -0
  31. package/dist/group-sharing-reader.d.ts.map +1 -1
  32. package/dist/index.d.ts +17 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2563 -510
  35. package/dist/index.js.map +1 -1
  36. package/dist/ingestion.js +1 -1
  37. package/dist/learner-history.d.ts +50 -1
  38. package/dist/learner-history.d.ts.map +1 -1
  39. package/dist/learner-plan-service.d.ts.map +1 -1
  40. package/dist/learner-service.d.ts +91 -2
  41. package/dist/learner-service.d.ts.map +1 -1
  42. package/dist/learners.d.ts +2 -0
  43. package/dist/learners.d.ts.map +1 -1
  44. package/dist/manifest.json +11774 -7626
  45. package/dist/question-response-service.d.ts +21 -3
  46. package/dist/question-response-service.d.ts.map +1 -1
  47. package/dist/smrt-knowledge.json +3507 -2008
  48. package/dist/tutor-context-service.d.ts +34 -0
  49. package/dist/tutor-context-service.d.ts.map +1 -0
  50. package/dist/tutor-contracts.d.ts +123 -0
  51. package/dist/tutor-contracts.d.ts.map +1 -0
  52. package/dist/tutor-models.d.ts +77 -0
  53. package/dist/tutor-models.d.ts.map +1 -0
  54. package/dist/tutor-session-service.d.ts +115 -0
  55. package/dist/tutor-session-service.d.ts.map +1 -0
  56. package/package.json +8 -8
  57. package/dist/chunks/workbook-photo-service-B7Tujv__.js.map +0 -1
package/AGENTS.md CHANGED
@@ -52,6 +52,11 @@ Structured questions from scans and imported course files use
52
52
  ingestion handler. Anchors come from provider region catalogs only; match by worksheet
53
53
  id and layout, never text; ambiguous matches stay unattached; source-keyed replays.
54
54
 
55
+ Course bundles (zip with a closed manifest, or a file set) use
56
+ [CourseBundleImportService](agents/course-bundle-import.md). Parse only with the
57
+ bounded reader; refuse, never trim, hostile input; unsupported members stay
58
+ visible; proposals only, nothing auto-published.
59
+
55
60
  Source import: [host boundary and transaction contract](agents/source-import.md).
56
61
  `SourceImportService` requires host opt-in, matching active actor/tenant and
57
62
  explicit licensing authorization; never add a system/global write bypass.
@@ -75,3 +80,7 @@ then revalidate each exact family edge and current policy. Media delivery must c
75
80
  the reader on every request; signed storage URLs are not a sharing grant. Saved
76
81
  parent comment visibility applies on every child read. Progress with feedback
77
82
  disabled contains counts only, never correctness, scores or concept labels.
83
+
84
+ Learner-safe tutor context and append-only tutor transcripts use
85
+ [TutorContextService and TutorSessionService](agents/tutor-context.md). Learner
86
+ audience never carries keys, rubrics, notes or storage URIs; no AI call here.
package/README.md CHANGED
@@ -17,7 +17,8 @@ authorizer, private media delivery, facet semantics and migration/test matrix.
17
17
 
18
18
  Guardians author courses with `CourseAuthoringService`: append-only course and
19
19
  lesson revisions, a reviewed release that materializes immutable lessons,
20
- reversible archive, and recorded enrollment pins and upgrades. See the
20
+ reversible archive, recorded enrollment pins and upgrades, a recorded
21
+ visibility (`family`, `groups`, `public`) and an education-owned cover image. See the
21
22
  [course authoring contract](agents/course-authoring-service.md).
22
23
 
23
24
  ## Install
@@ -101,7 +102,27 @@ host's permission decision. Sibling membership does not grant access.
101
102
  `putBackLesson` returns started work to `not_started` and `setEnrollmentStatus`
102
103
  starts, pauses, resumes or drops an enrollment along a fixed transition table;
103
104
  both append immutable history (actor, and for status the host policy basis)
104
- and never touch submissions, grades or other progress.
105
+ and never touch submissions, grades or other progress. `completeOwnLesson`
106
+ lets a learner mark their own lesson of any kind done when the host's
107
+ `education.progress.complete-own` decision allows it, recording the actor, the
108
+ host setting and optional evidence submission; guardian `attestLesson` is
109
+ unchanged. `setEnrollmentPace` sets or clears an enrollment's planned lessons
110
+ per week (1 to 14) under the host's `education.enrollment.set-pace` decision with
111
+ an immutable change row, and `nextLessons({ count })` returns a week's set of
112
+ unfinished lessons (default one).
113
+ `TutorContextService.compose` builds a bounded, learner-safe `TutorContext`
114
+ (lesson text, learner-visible questions, own progress, released feedback; for the
115
+ learner audience only `answerKeyAvailable`, never keys, rubrics, notes or storage
116
+ URIs) under the host's `education.tutor.read` decision, and `TutorSessionService`
117
+ (`open`, `append`, `close`, `listForLearner`; `education.tutor.exchange` and
118
+ `education.tutor.read`) keeps append-only guardian-readable transcripts. No model
119
+ call happens in the package; see [tutor contract](agents/tutor-context.md).
120
+
121
+ `CourseBundleImportService` imports a course zip (optional closed `manifest.json`)
122
+ or file set under hard size, ratio, path and entry bounds, keeps unsupported and
123
+ duplicate members as append-only evidence, hands accepted members to ingestion and
124
+ proposes sections and lessons through `CourseGenerationReviewService`; see
125
+ [bundle import](agents/course-bundle-import.md).
105
126
  Repeated starts preserve the existing progress, including attempts and scores.
106
127
  A new curriculum/year creates a distinct enrollment and fresh progress history.
107
128
  An unscored lesson has `bestScore: null`, distinct from zero. The service does
@@ -21,7 +21,7 @@ change to existing tables, test-pinned); the runtime never creates tables.
21
21
 
22
22
  ## Actions and host mapping
23
23
 
24
- `education.course.read-draft|author|generate|review|release|assign|archive`. The host
24
+ `education.course.read-draft|author|generate|review|release|assign|archive|set-visibility|set-cover`. The host
25
25
  maps roles; the authorizer also receives `operation`, `courseId`,
26
26
  `enrollmentId`/`learnerId` and `delegatingGuardianUserId` (agent calls) and must
27
27
  re-validate that delegation on the supplied executor. The service checks the
@@ -144,6 +144,49 @@ course is not-found, a foreign enrollment a denial). Lists take `limit` (default
144
144
  enrollment's effective lessons (else `QuestionResponseScopeError`), keyless bundle,
145
145
  no materials or provenance, personal-use body withheld (`bodyWithheld`).
146
146
 
147
+ ## Visibility and cover
148
+
149
+ Two optional attributes live on the stable `AuthoredCourse` identity row (added
150
+ columns `visibility`, `cover_asset_id` on `courses`; additive, migration-tested on
151
+ a populated database). Each change is an append-only row (`CourseVisibilityChange`,
152
+ `CourseCoverChange`: previous, new, actor, delegation, request key, time) and every
153
+ write needs a `requestKey`; the same key with another payload is
154
+ `CourseRequestKeyConflictError`, an unchanged value writes nothing. Reads degrade
155
+ to `family` and no cover on a database without the columns; a write without the
156
+ change tables throws `schema-unavailable`.
157
+
158
+ - `setVisibility({ courseId, visibility: 'family'|'groups'|'public', requestKey })`
159
+ (action `education.course.set-visibility`, operation `set-visibility`). Default
160
+ is `family`. A stored value of `''` (never set, including every course authored
161
+ before this existed) reads as `family`; an explicit `setVisibility('family')` on
162
+ such a course is recorded (it is how a host pins a course family-only).
163
+ A course whose licence is `personal-use-copy` (head revision or any release,
164
+ which never loosens) refuses `groups` and `public` with
165
+ `CourseVisibilityRefusedError` (`visibility-refused`). A revision that turns a
166
+ `groups`/`public` course personal-use narrows it to `family` in the same
167
+ transaction with a change row (`reason: 'licence'`, request key
168
+ `licence:<revisionId>`). `AuthoredCourse.save()` also refuses the combination.
169
+ - `listCourses({ visibility })` filters by effective visibility; views
170
+ (`CourseSummaryView`, `CourseStateView`) carry `visibility` and `coverAssetId`.
171
+ - `setCover({ courseId, assetId: string | null, requestKey })` (action
172
+ `education.course.set-cover`, operation `set-cover`; `null` clears). The asset
173
+ must be a material this course copied into the tenant (`materialCopy`: the only
174
+ education-owned provenance the service records), with image content type
175
+ (png, jpeg, webp, gif), in the course's tenant, never `answer-key` in any lesson
176
+ revision or released lesson link, and readable by the acting user through the
177
+ asset guard (a private asset only when its owner is the actor's profile).
178
+ Anything else is `CourseCoverInvalidError` (`cover-invalid`, `reason`:
179
+ `missing`, `not-image`, `not-course-material`, `answer-key`, `unreadable`);
180
+ fail closed. A host that wants a standalone cover adds the image as an
181
+ attachment material of a lesson first.
182
+ - `release` snapshots the current cover onto the immutable release Course row
183
+ (re-checked for eligibility, not for the releasing actor's read; dropped if no
184
+ longer eligible). `CourseReleaseView.coverAssetId` and
185
+ `latestRelease.coverAssetId` report it; later cover changes never rewrite it.
186
+ - Group sharing: see [group sharing](group-sharing.md#authored-course-visibility).
187
+ `public` is a read-filter label: no marketplace, no payment, and no cross-tenant
188
+ read exists for it.
189
+
147
190
  ## Archive and discard
148
191
 
149
192
  `archive`/`unarchive` append reversible decisions, mirror release `Course.status`
@@ -0,0 +1,148 @@
1
+ # Course bundle import
2
+
3
+ `CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,
4
+ consumed by Hearth #94) imports a course bundle, a zip with an optional manifest
5
+ or a plain file set, as bounded evidence, hands its supported members to the
6
+ host for ingestion extraction and proposes sections and lessons for review.
7
+ Risk is high: untrusted archive parsing and new schema. It reuses the course
8
+ authorizer: `importBundle` needs `education.course.author`, `readImport`
9
+ `education.course.read-draft`, `proposeStructure` `education.course.generate`
10
+ (via `CourseGenerationReviewService`). Operations: `import-course-bundle`,
11
+ `read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.
12
+
13
+ ## Flow
14
+
15
+ 1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`
16
+ authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays
17
+ out the structure (`classify`), writes the evidence in one transaction and
18
+ then calls `receiveMember` for each accepted member (`extract`), outside any
19
+ transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,
20
+ bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`
21
+ (`reason`, optional `entry`) and writes nothing.
22
+ 2. `receiveMember(member, { replayed })` is the host's ingestion seam: call
23
+ `IngestionService.receive` with `sourceKind: 'course_import'` and the member
24
+ bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in
25
+ [question extraction](question-extraction.md). Members carry `role`
26
+ (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too
27
+ (ingestion dedups by content hash), so an interrupted first run resumes. Its
28
+ return values are in `result.receipts`.
29
+ 3. `proposeStructure({ ...actor, importId, bundle, courseId,
30
+ expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes
31
+ are never retained), checks it is the imported one, and runs a deterministic
32
+ `CourseMaterialGenerator` (`smrt-bundle-import`) through
33
+ `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a
34
+ new draft revision of additions and fills; conflicts wait for
35
+ `decideProposal`. Nothing is released, assigned or published. The request key
36
+ is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the
37
+ same key with another head is `CourseRequestKeyConflictError`.
38
+ 4. `readImport({ ...actor, importId })` returns the recorded evidence.
39
+
40
+ Progress is an observer callback (`onProgress({ stage, completed, total })`,
41
+ stages `unpack`, `classify`, `extract`, `propose`; each starts at `completed: 0`).
42
+ Ingestion has no public job-progress seam, so none is invented. A throwing
43
+ listener never affects the import.
44
+
45
+ ## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)
46
+
47
+ | limit | value |
48
+ |---|---|
49
+ | `maxArchiveBytes` (input) | 100 MiB |
50
+ | `maxEntries` | 200 |
51
+ | `maxEntryBytes` (inflated) | 50 MiB |
52
+ | `maxTotalBytes` (inflated) | 200 MiB |
53
+ | `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |
54
+ | `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |
55
+ | `maxManifestBytes` | 256 KiB |
56
+ | `maxLessons` (the proposal schema's cap) | 50 |
57
+ | `maxLessonBodyChars` | 100 000 |
58
+ | archive nesting | 1: a zip inside a zip is never expanded |
59
+
60
+ A lesson whose combined text members exceed `maxLessonBodyChars` refuses the
61
+ bundle (`lesson_too_large`, `entry` = lesson key) at import; text is never
62
+ trimmed or dropped. A plain file set enforces `maxTotalBytes` on its member
63
+ bytes exactly as an archive does on inflated bytes.
64
+
65
+ The reader (`course-bundle-zip.ts`) parses the end record and central directory,
66
+ validates every offset and size against the real buffer, inflates with
67
+ `maxOutputLength` equal to the declared size (declared sizes are never trusted:
68
+ a mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total
69
+ on actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)
70
+ cover: not a zip, truncated, ambiguous end record (two end records that both fit
71
+ the file, for example one forged in a comment), zip64, multi-disk, central
72
+ directory not ending at the end record, entries out of range or overlapping,
73
+ local header name/method mismatch, encrypted entries (flags 0/6/13, AES method),
74
+ symlink and special files (unix mode; DOS reparse flag), unsupported method,
75
+ bad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,
76
+ backslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,
77
+ drive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,
78
+ too long or deep). An **honestly** oversize member (declared over
79
+ `maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.
80
+
81
+ ## Members and statuses
82
+
83
+ By content (never by extension alone): `pdf` (`%PDF-` in the first 1 KiB),
84
+ `image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control
85
+ characters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.
86
+ Status: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier
87
+ member in canonical order; recorded once as evidence, `duplicateOfPath`, never
88
+ extracted again), `unsupported` (`unsupported_type`, `nested_archive`,
89
+ `unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`
90
+ (the manifest). Nothing is dropped. An encrypted PDF is any PDF with an
91
+ `/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive
92
+ on literal text inside an uncompressed stream is refused, never read.
93
+
94
+ ## Manifest (`manifest.json`, version 1, closed)
95
+
96
+ ```json
97
+ { "version": 1, "course": { "subject": "Math" },
98
+ "sections": [ { "key": "unit-1", "title": "Unit One",
99
+ "lessons": [ { "key": "sums", "title": "Sums",
100
+ "members": [ { "path": "sheets/sums.pdf", "role": "exercise" } ] } ] } ] }
101
+ ```
102
+ Every object is closed: an unknown key anywhere is `manifest_unknown_key` (with
103
+ its path in `entry`); another version is `manifest_version`. Keys match
104
+ `[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles
105
+ (`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,
106
+ `unknown`); a listed path must exist and be listed once. The manifest is a source
107
+ fact: roles are `source`, anything the extractor infers stays `inferred`, and a
108
+ manifest/extraction disagreement belongs in extraction review, not here. Without
109
+ a manifest each accepted member is one lesson, sections come from the top-level
110
+ directory and order is natural path order (digit runs compare numerically, no
111
+ locale). Manifest order: the manifest, declared members, then the rest by path.
112
+ Lesson bodies are the readable text members of a lesson, joined; PDFs and images
113
+ are never inlined. Members whose declared role is `answer_key` or `mixed` never
114
+ contribute text to a lesson body (they remain recorded evidence members); the
115
+ proposal rationale only counts how many were withheld.
116
+
117
+ ## Readers
118
+
119
+ `BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;
120
+ built-ins are `manifest-v1` and `plain-files`. SCORM and Common Cartridge are
121
+ **not implemented** (their manifests describe launch resources and activity
122
+ trees that do not map to sections/lessons without design); a host can register
123
+ a reader ahead of the built-ins (`register(reader, { first: true })`).
124
+
125
+ ## Identity and replay
126
+
127
+ `bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so
128
+ re-zipping, reordering and timestamps replay. One `CourseBundleImport` per
129
+ (tenant, identity); a concurrent first import replays once on the unique index.
130
+
131
+ ## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)
132
+
133
+ `CourseBundleImport` (`course_bundle_imports`: identity, format, reader,
134
+ manifest version, counts, actor) and `CourseBundleMember` (`course_bundle_members`:
135
+ unique tenant/import/path; ordinal, sha256, size, media type, kind, status,
136
+ reason, duplicate-of, role, section and lesson key). Hashes and placements only:
137
+ bytes stay with the host's ingestion receipts. Rewrites and deletes throw.
138
+ Normal manifest migrations create both tables.
139
+
140
+ ## Not in this release
141
+
142
+ - Ingestion's receipt contract is unchanged: there is no `application/zip` part
143
+ type or `parentEvidenceId` in ingestion. The education side unpacks and the
144
+ host receives members one by one, so archive-to-member lineage lives in
145
+ `course_bundle_members` (and the host's receipts), not in ingestion evidence.
146
+ Changing ingestion's published receipt is a separate design decision.
147
+ - A manifest/extraction disagreement is not compared automatically.
148
+ - SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.
@@ -276,3 +276,23 @@ family kind. The helper provides no arbitrary cross-tenant directory.
276
276
  | Behavior/invariant | Trigger | Positive | Negative/failure | Actor/context | Executor/transaction | Runtime/dialect | External edge | Level | Command |
277
277
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
278
278
  | G33 workspace kind | trusted exact resource lookup | existing family false; active/inactive group true | blank/malformed/absent tenant; ambiguous group rows; PostgreSQL RLS-hidden or indeterminate zero refuses family; database failures propagate | host-selected exact tenant, no synthetic session/context | caller-supplied executor, fresh reads; no writes | Node SQLite/PostgreSQL | status/membership/role absence never implies family or grants | integration | `pnpm --filter @happyvertical/smrt-education exec vitest run src/__tests__/group-sharing-workspace.test.ts` (same file with TEST_DB_ADAPTER=postgres and isolated DATABASE_URL) |
279
+
280
+ ## Authored course visibility
281
+
282
+ Authored-course `visibility` (see [course authoring](course-authoring-service.md))
283
+ only ever narrows group sharing; it never adds a reader, a tenant or a facet.
284
+
285
+ Before: every authored release that was not personal-use was eligible (policy
286
+ options, `listSharedWork`, preview) subject to the policy, edge and review rules.
287
+ After: the same, except an authored release whose identity is explicitly recorded
288
+ as `family` (including any unrecognized stored value) is `course-private`: it is
289
+ absent from the policy options and its work evaluates `course-private`, also when
290
+ only another release of that course is in the enrollment's pin lineage.
291
+ `groups` and `public` change nothing relative to before. A course whose visibility
292
+ was never recorded (everything authored before visibility existed) also behaves as
293
+ before, so existing contracts and tests are unchanged; hosts that want a default
294
+ of "family only" call `setVisibility('family')` explicitly. `public` is a listing
295
+ label only; no reviewed cross-tenant read path exists for it, so it grants nothing.
296
+ Non-authored courses, answer keys and personal-use rules are untouched, and a
297
+ database without the visibility column or release tables reads as never recorded.
298
+ The gate is `authoredVisibilityPermitsGroups(db, tenantId, releaseCourseId)`.
@@ -101,6 +101,41 @@ Same-tenant membership alone never grants sibling access.
101
101
  submissions and history never cascade. Bad input throws
102
102
  `LearnerValidationError`; a host without `enrollment_status_changes` gets
103
103
  `LearnerHistoryUnavailableError`.
104
+ - `completeOwnLesson({ enrollmentId, lessonId, policyBasis, evidence?: { submissionId } })`
105
+ moves progress to `complete` (any lesson kind, `completedAt` set, a missing
106
+ row created) under `education.progress.complete-own`; the host decides per
107
+ setting and may allow a guardian too (own is not blocked). It appends an
108
+ immutable `LessonCompletionRecord` (actor, `policyBasis`, prior status,
109
+ optional evidence submission, at most one per progress) and a `complete`
110
+ `LessonProgressTransition` when that optional table exists. `evidence`
111
+ must be a non-rejected submission of the same tenant, learner, lesson and
112
+ progress, else `LearnerAuthorizationError`. Counters, score and start time
113
+ are untouched; no score is invented. Returns `{ changed, progress }`.
114
+ Repeat rule: a lesson already completed through this path (a record exists)
115
+ returns `{ changed: false }`; one complete any other way (guardian
116
+ `attestLesson`/grade, workbook completion) throws `LessonAlreadyCompleteError`.
117
+ Also `EnrollmentNotActiveError` (`.status`; only `active` enrollments),
118
+ `LessonSkippedError`, `WorkbookOwnedLessonError` (a workbook lesson completes
119
+ through its workbook decision), and `LearnerHistoryUnavailableError` without
120
+ `lesson_completion_records`. Guardian `attestLesson` is unchanged.
121
+ - `setEnrollmentPace({ enrollmentId, pace: { lessonsPerWeek } | null, policyBasis })`
122
+ sets or clears the enrollment's planned lessons per week under
123
+ `education.enrollment.set-pace` (the host decides whether a learner may act
124
+ on their own enrollment). `lessonsPerWeek` is a whole number from 1 to 14
125
+ (`MIN_/MAX_LESSONS_PER_WEEK`), else `EnrollmentPaceError`. It is stored as the
126
+ integer column `enrollments.pace_lessons_per_week` (`0` = no pace, like
127
+ `targetMinutesPerWeek`) and surfaces as `EnrollmentView.pace` (`null` when
128
+ unset). Each change appends an immutable `EnrollmentPaceChange` (actor,
129
+ `policyBasis`, previous and new value, `0` = none); an unchanged pace is
130
+ `{ changed: false }` with no row. Status and progress are untouched. A host
131
+ without `enrollment_pace_changes` gets `LearnerHistoryUnavailableError`.
132
+ Apply the additive migration (new column plus table) before deploying writes;
133
+ reads of an un-migrated table report no pace.
134
+ - `nextLessons({ enrollmentId?, count? })`: `count` is an integer from 1 to
135
+ 14 (`MAX_NEXT_LESSONS_COUNT`, default 1, else `LearnerValidationError`) and
136
+ returns up to that many unfinished (not complete or skipped) lessons per
137
+ enrollment in lesson order, as the same flat entries; the default is
138
+ unchanged.
104
139
  - `listProgress` reads only the validated learner/enrollment and lessons still
105
140
  visible in that enrollment's course. Inconsistent persisted links fail
106
141
  closed or are omitted from the list.
@@ -104,5 +104,8 @@ Both ship through normal manifest migrations. The originals stay in ingestion
104
104
  and are never copied. Imported material keeps its course licence; extraction
105
105
  does not change sharing.
106
106
 
107
- Not in this release: zip/bundle manifests (SMRT #4578), automatic new
107
+ Zip and manifest bundles are handled before extraction by
108
+ [CourseBundleImportService](course-bundle-import.md) (SMRT #4578).
109
+
110
+ Not in this release: automatic new
108
111
  revisions on `source_changed`, and media assets for extracted diagrams.
@@ -0,0 +1,62 @@
1
+ # Tutor context and sessions
2
+
3
+ No AI call, prompt text or provider lives here. The host builds prompts from
4
+ `TutorContext` and owns the model call and spend ceiling; this package records
5
+ what was said.
6
+
7
+ ## TutorContextService.compose
8
+
9
+ `compose({ ...LearnerRequest, audience: 'learner' | 'guardian', enrollmentId?,
10
+ lessonId?, questionRevisionId? })` extends `QuestionResponseService`; every read uses
11
+ the service executor (the transaction's inside `withTransaction`). The host
12
+ authorizer is asked `education.tutor.read` with `own` and `relationshipTypeId`
13
+ as for `LearnerService`. A lesson needs its enrollment and must be in the
14
+ enrollment's effective set; a focus question must belong to that lesson
15
+ (otherwise `LearnerAuthorizationError`). The guardian audience is refused for
16
+ the learner's own actor whatever the host answers.
17
+
18
+ Result: learner (name, level), enrollment (status, pace; no notes), course,
19
+ section and lesson titles, learner-facing lesson body (withheld for
20
+ personal-use-copy), objectives, own progress status, the learner-visible
21
+ question rendering (prompt, response type, choices, subpart prompts, points; no
22
+ media or anchors), submission count with latest status only, and the newest
23
+ **released** feedback text (guardian: newest guardian-confirmed revision), plus
24
+ `answerKeyAvailable`, `truncated` and `contextHash`.
25
+
26
+ Learner audience never carries answer keys, rubrics, key assets or their ids,
27
+ storage URIs, enrollment parent notes, grades, other learners or grading
28
+ internals: it is built from an allowlist and keys are read only to set the
29
+ boolean. The guardian audience adds capped answer-key and rubric text, still no
30
+ storage locators or notes. Do not add fields without a sentinel test in
31
+ `tutor-context.test.ts`.
32
+
33
+ Bounds (exported constants): body 8000, prompt/choice/subpart 2000, key 2000,
34
+ feedback 2000, titles 300, 30 questions (the focus question is kept), 12 choices
35
+ and subparts, 10 objectives, 48000 serialized characters overall (trailing
36
+ questions drop first). Any cut sets `truncated`.
37
+
38
+ ## TutorSessionService
39
+
40
+ Append-only `TutorSession`, `TutorSessionClosure` and `TutorExchange` (new
41
+ tables `tutor_sessions`, `tutor_session_closures`, `tutor_exchanges`;
42
+ tenant-scoped, closed generated surfaces, update/delete rejected). A session is
43
+ open until its single closure row exists. Sessions store ids, audience, mode,
44
+ actor, voice consent and the context digest, never context text.
45
+
46
+ - `open` (`education.tutor.exchange`): `requestKey` idempotent per learner; a
47
+ replay with a changed payload is `TutorRequestKeyConflictError`. Voice needs
48
+ `voiceConsent: true` the host recorded (`TutorConsentRequiredError`). Guardian
49
+ audience is refused for the learner's own actor.
50
+ - `append` (`education.tutor.exchange`): role `learner|tutor|system`; `learner`
51
+ only from the learner themself. Text 1 to 4000 characters else
52
+ `TutorExchangeError`. Idempotent per session `requestKey`; sequence is
53
+ server-assigned from 1; closed session is `TutorSessionClosedError`. Optional
54
+ `marker` (`none|filtered|redacted|flagged`), `toolSummary`, token counts.
55
+ - `close`: appends the closure; closing twice returns `changed: false`.
56
+ - `listForLearner` (`education.tutor.read`): sessions newest first, `limit`
57
+ (<=50) and `cursor`, per-session exchanges bounded by `exchangeLimit` (<=200)
58
+ with `exchangesTruncated`; `sessionId` + `afterSequence` page one transcript.
59
+ A guardian reads through the host's decision on the relationship.
60
+
61
+ Missing tables raise `LearnerHistoryUnavailableError`. Concurrent appends rely on
62
+ the unique `(tenant, session, sequence)` index.
package/dist/catalog.d.ts CHANGED
@@ -29,7 +29,19 @@ export declare class Course extends SmrtObject {
29
29
  }
30
30
  export declare class ExternalCourse extends Course {
31
31
  }
32
+ /** Who an authored course is meant for: its family, its family's groups, a public listing label. */
33
+ export declare const COURSE_VISIBILITIES: readonly ["family", "groups", "public"];
34
+ export type CourseVisibility = (typeof COURSE_VISIBILITIES)[number];
32
35
  export declare class AuthoredCourse extends Course {
36
+ /**
37
+ * Authored-course visibility; '' means never set (reads as 'family'). Only the
38
+ * course-authoring service writes it, with an append-only change row.
39
+ */
40
+ visibility: CourseVisibility | '';
41
+ /** Education-owned image asset shown as the course cover; '' means none. */
42
+ coverAssetId: string;
43
+ constructor(options?: Record<string, unknown>);
44
+ save(options?: SmrtSaveOptions): Promise<this>;
33
45
  }
34
46
  export declare class CourseSection extends SmrtObject {
35
47
  tenantId: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,UAAU,EACV,KAAK,eAAe,EAErB,MAAM,0BAA0B,CAAC;AAGlC,MAAM,MAAM,iBAAiB,GACzB,UAAU,GACV,cAAc,GACd,mBAAmB,GACnB,sBAAsB,CAAC;AAC3B,MAAM,MAAM,cAAc,GACtB,QAAQ,GACR,WAAW,GACX,KAAK,GACL,OAAO,GACP,MAAM,GACN,OAAO,CAAC;AACZ,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,UAAU,CAAC;AAC9D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;AAwB9E,qBAEa,MAAO,SAAQ,UAAU;IACN,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAQ;IAClC,KAAK,SAAM;IACtC,OAAO,SAAM;IACb,WAAW,SAAM;IACjB,SAAS,SAAY;IACrB,SAAS,SAAM;IACf,SAAS,SAAM;IACf,OAAO,SAAM;IACb,WAAW,EAAE,iBAAiB,CAAc;IAC5C,WAAW,SAAM;IACjB,UAAU,SAAM;IAChB,OAAO,SAAM;IACb,IAAI,SAAM;IACV,QAAQ,SAAK;IACb,QAAQ,SAAK;IACb,UAAU,SAAO;IACjB,gBAAgB,SAAK;IACrB,QAAQ,EAAE,cAAc,CAAY;IACpC,MAAM,EAAE,YAAY,CAAW;gBACnB,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAKjD,yEAAyE;IAC1D,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAyC9D;AAED,qBACa,cAAe,SAAQ,MAAM;CAAG;AAE7C,qBACa,cAAe,SAAQ,MAAM;CAAG;AAE7C,qBAEa,aAAc,SAAQ,UAAU;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAQ;IACnB,QAAQ,SAAM;IAC7B,KAAK,SAAM;IACtC,SAAS,SAAK;gBACF,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAIlC,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAmB9D;AAED,qBAEa,MAAO,SAAQ,UAAU;IACN,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAQ;IACZ,SAAS,SAAM;IACrC,KAAK,SAAM;IACtC,IAAI,SAAM;IACV,SAAS,SAAK;IACd,gBAAgB,SAAK;IACrB,UAAU,SAAO;IACjB,QAAQ,EAAE,cAAc,CAAY;IACpC,cAAc,EAAE,oBAAoB,CAAY;IAChD,WAAW,SAAM;IACjB,WAAW,SAAM;gBACL,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAIlC,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAmB9D;AAED,qBACa,OAAQ,SAAQ,MAAM;CAAG;AACtC,qBACa,KAAM,SAAQ,MAAM;IAC/B,QAAQ,SAAM;gBACF,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;CAIlD;AACD,qBACa,SAAU,SAAQ,MAAM;IACnC,YAAY,SAAM;IAClB,SAAS,SAAM;IACf,KAAK,SAAK;IACD,cAAc,EAAE,oBAAoB,CAAY;gBAC7C,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;CAIlD;AACD,qBACa,QAAS,SAAQ,MAAM;CAAG"}
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,UAAU,EACV,KAAK,eAAe,EAErB,MAAM,0BAA0B,CAAC;AAGlC,MAAM,MAAM,iBAAiB,GACzB,UAAU,GACV,cAAc,GACd,mBAAmB,GACnB,sBAAsB,CAAC;AAC3B,MAAM,MAAM,cAAc,GACtB,QAAQ,GACR,WAAW,GACX,KAAK,GACL,OAAO,GACP,MAAM,GACN,OAAO,CAAC;AACZ,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,UAAU,CAAC;AAC9D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;AAwB9E,qBAEa,MAAO,SAAQ,UAAU;IACN,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAQ;IAClC,KAAK,SAAM;IACtC,OAAO,SAAM;IACb,WAAW,SAAM;IACjB,SAAS,SAAY;IACrB,SAAS,SAAM;IACf,SAAS,SAAM;IACf,OAAO,SAAM;IACb,WAAW,EAAE,iBAAiB,CAAc;IAC5C,WAAW,SAAM;IACjB,UAAU,SAAM;IAChB,OAAO,SAAM;IACb,IAAI,SAAM;IACV,QAAQ,SAAK;IACb,QAAQ,SAAK;IACb,UAAU,SAAO;IACjB,gBAAgB,SAAK;IACrB,QAAQ,EAAE,cAAc,CAAY;IACpC,MAAM,EAAE,YAAY,CAAW;gBACnB,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAKjD,yEAAyE;IAC1D,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAyC9D;AAED,qBACa,cAAe,SAAQ,MAAM;CAAG;AAE7C,oGAAoG;AACpG,eAAO,MAAM,mBAAmB,yCAA0C,CAAC;AAC3E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE,qBACa,cAAe,SAAQ,MAAM;IACxC;;;OAGG;IACH,UAAU,EAAE,gBAAgB,GAAG,EAAE,CAAM;IACvC,4EAA4E;IAC5E,YAAY,SAAM;gBACN,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAIlC,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAQ9D;AAED,qBAEa,aAAc,SAAQ,UAAU;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAQ;IACnB,QAAQ,SAAM;IAC7B,KAAK,SAAM;IACtC,SAAS,SAAK;gBACF,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAIlC,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAmB9D;AAED,qBAEa,MAAO,SAAQ,UAAU;IACN,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAQ;IACZ,SAAS,SAAM;IACrC,KAAK,SAAM;IACtC,IAAI,SAAM;IACV,SAAS,SAAK;IACd,gBAAgB,SAAK;IACrB,UAAU,SAAO;IACjB,QAAQ,EAAE,cAAc,CAAY;IACpC,cAAc,EAAE,oBAAoB,CAAY;IAChD,WAAW,SAAM;IACjB,WAAW,SAAM;gBACL,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAIlC,IAAI,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAmB9D;AAED,qBACa,OAAQ,SAAQ,MAAM;CAAG;AACtC,qBACa,KAAM,SAAQ,MAAM;IAC/B,QAAQ,SAAM;gBACF,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;CAIlD;AACD,qBACa,SAAU,SAAQ,MAAM;IACnC,YAAY,SAAM;IAClB,SAAS,SAAM;IACf,KAAK,SAAK;IACD,cAAc,EAAE,oBAAoB,CAAY;gBAC7C,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;CAIlD;AACD,qBACa,QAAS,SAAQ,MAAM;CAAG"}