@happyvertical/smrt-education 0.57.9 → 0.57.10
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/catalog-sync.md +35 -0
- package/agents/ingestion.md +93 -0
- package/agents/learner-service.md +9 -0
- package/agents/source-import.md +9 -0
- package/agents/submission-service.md +18 -0
- package/dist/catalog-acquisition-service.d.ts +58 -0
- package/dist/catalog-acquisition-service.d.ts.map +1 -0
- package/dist/catalog-collections.d.ts +18 -0
- package/dist/catalog-collections.d.ts.map +1 -0
- package/dist/catalog-contracts.d.ts +88 -0
- package/dist/catalog-contracts.d.ts.map +1 -0
- package/dist/catalog-database-sync-store.d.ts +13 -0
- package/dist/catalog-database-sync-store.d.ts.map +1 -0
- package/dist/catalog-materialization-service.d.ts +78 -0
- package/dist/catalog-materialization-service.d.ts.map +1 -0
- package/dist/catalog-models.d.ts +63 -0
- package/dist/catalog-models.d.ts.map +1 -0
- package/dist/catalog-policy.d.ts +12 -0
- package/dist/catalog-policy.d.ts.map +1 -0
- package/dist/catalog-read-service.d.ts +44 -0
- package/dist/catalog-read-service.d.ts.map +1 -0
- package/dist/catalog-sync-service.d.ts +19 -0
- package/dist/catalog-sync-service.d.ts.map +1 -0
- package/dist/collections.d.ts +4 -1
- package/dist/collections.d.ts.map +1 -1
- package/dist/hearth-catalog-providers.d.ts +74 -0
- package/dist/hearth-catalog-providers.d.ts.map +1 -0
- package/dist/hearth-easy-peasy-grade4.d.ts +40 -0
- package/dist/hearth-easy-peasy-grade4.d.ts.map +1 -0
- package/dist/index.d.ts +28 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2279 -175
- package/dist/index.js.map +1 -1
- package/dist/ingestion-isolation.d.ts +8 -0
- package/dist/ingestion-isolation.d.ts.map +1 -0
- package/dist/ingestion.d.ts +103 -0
- package/dist/ingestion.d.ts.map +1 -0
- package/dist/ingestion.js +364 -0
- package/dist/ingestion.js.map +1 -0
- package/dist/learner-service.d.ts +74 -1
- package/dist/learner-service.d.ts.map +1 -1
- package/dist/manifest.json +2686 -481
- package/dist/smrt-knowledge.json +713 -14
- package/dist/source-import-service.d.ts +11 -1
- package/dist/source-import-service.d.ts.map +1 -1
- package/dist/sources/easy-peasy-parser.d.ts.map +1 -1
- package/dist/sources/google-drive-adapter.d.ts +125 -0
- package/dist/sources/google-drive-adapter.d.ts.map +1 -0
- package/dist/sources/illustrative-mathematics-parser.d.ts +8 -0
- package/dist/sources/illustrative-mathematics-parser.d.ts.map +1 -0
- package/dist/sources/oer-commons-rss-parser.d.ts +8 -0
- package/dist/sources/oer-commons-rss-parser.d.ts.map +1 -0
- package/dist/submission-service.d.ts +48 -1
- package/dist/submission-service.d.ts.map +1 -1
- package/dist/submissions.d.ts +18 -0
- package/dist/submissions.d.ts.map +1 -1
- package/package.json +16 -10
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Verify a real Linux cgroup-v2 native-memory ceiling before enabling decoders.
|
|
2
|
+
* The worker and its children must remain in this cgroup; deployments must deny
|
|
3
|
+
* cgroup writes/escape and run without privileged container capabilities.
|
|
4
|
+
*/
|
|
5
|
+
export declare function requireIngestionMemoryLimit(maximumBytes: number): Promise<{
|
|
6
|
+
memoryLimitBytes: number;
|
|
7
|
+
}>;
|
|
8
|
+
//# sourceMappingURL=ingestion-isolation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingestion-isolation.d.ts","sourceRoot":"","sources":["../src/ingestion-isolation.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAsB,2BAA2B,CAC/C,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,CAAC,CAmCvC"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Server-only paper workflow integration. Never import from browser handlers. */
|
|
2
|
+
import { type ExtractionAdapter, type ExtractionLimits, type IngestionService } from '@happyvertical/smrt-ingestion/server';
|
|
3
|
+
export { requireIngestionMemoryLimit } from './ingestion-isolation.js';
|
|
4
|
+
/** Immutable operator configuration; never populated from upload/model metadata. */
|
|
5
|
+
export interface EducationExtractionConfiguration {
|
|
6
|
+
configurationRevision: string;
|
|
7
|
+
limits: ExtractionLimits;
|
|
8
|
+
}
|
|
9
|
+
/** Allocate extraction after a durable receipt. The normal ingestion outbox
|
|
10
|
+
* dispatches this revision to the application's registered job target.
|
|
11
|
+
*/
|
|
12
|
+
export declare function queueEducationExtraction(ingestion: IngestionService, itemId: string, configuration: EducationExtractionConfiguration, submissionId?: string): Promise<{
|
|
13
|
+
id: string;
|
|
14
|
+
revision: number;
|
|
15
|
+
}>;
|
|
16
|
+
/** A registered job invokes this with its persisted item/revision IDs and an
|
|
17
|
+
* authenticated service scope. Providers execute outside database transactions.
|
|
18
|
+
* Interpretation receives ingestion's immutable extraction pin. Neither stage
|
|
19
|
+
* approves a suggestion, captures a submission, or applies a grade.
|
|
20
|
+
*/
|
|
21
|
+
export declare function processEducationAnalysis(input: {
|
|
22
|
+
ingestion: IngestionService;
|
|
23
|
+
itemId: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
workerId: string;
|
|
26
|
+
adapter: ExtractionAdapter;
|
|
27
|
+
extraction: EducationExtractionConfiguration;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
kind: 'unclaimed' | 'extraction_incomplete' | 'interpreted';
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'generation_queued';
|
|
32
|
+
revision: number;
|
|
33
|
+
}>;
|
|
34
|
+
import type { IngestionScope } from '@happyvertical/smrt-ingestion/server';
|
|
35
|
+
import type { LearnerRequest } from './learner-service.js';
|
|
36
|
+
import type { SubmissionService } from './submission-service.js';
|
|
37
|
+
export type EducationIntakeCaptureRequest = LearnerRequest & {
|
|
38
|
+
enrollmentId: string;
|
|
39
|
+
lessonId: string;
|
|
40
|
+
itemId: string;
|
|
41
|
+
kind: 'blank_reference' | 'completed_work';
|
|
42
|
+
previousSubmissionId?: string;
|
|
43
|
+
notes?: string;
|
|
44
|
+
};
|
|
45
|
+
/** Copy immutable retained originals into education's private ownership only
|
|
46
|
+
* after the host has obtained explicit assignment confirmation. Supply service
|
|
47
|
+
* and scope from the SAME authenticated server factory, never browser values.
|
|
48
|
+
* The owning service persists unique tenant/item binding before allowing retries.
|
|
49
|
+
*/
|
|
50
|
+
export declare function captureEducationIntake(input: {
|
|
51
|
+
ingestion: IngestionService;
|
|
52
|
+
scope: Readonly<IngestionScope>;
|
|
53
|
+
submissions: SubmissionService;
|
|
54
|
+
request: EducationIntakeCaptureRequest;
|
|
55
|
+
}): Promise<Readonly<{
|
|
56
|
+
id: string;
|
|
57
|
+
learnerId: string;
|
|
58
|
+
lessonId: string;
|
|
59
|
+
progressId: string;
|
|
60
|
+
status: import("./submissions.js").Submission["status"];
|
|
61
|
+
submittedAt: Date;
|
|
62
|
+
notes: string;
|
|
63
|
+
kind: import("./submissions.js").SubmissionKind;
|
|
64
|
+
previousSubmissionId: string | null;
|
|
65
|
+
intakeItemId: string | null;
|
|
66
|
+
pages: ReadonlyArray<{
|
|
67
|
+
assetId: string;
|
|
68
|
+
sortOrder: number;
|
|
69
|
+
}>;
|
|
70
|
+
grade: null | {
|
|
71
|
+
id: string;
|
|
72
|
+
graderKind: import("./submissions.js").Grade["graderKind"];
|
|
73
|
+
score: number | null;
|
|
74
|
+
feedback: string;
|
|
75
|
+
};
|
|
76
|
+
}>>;
|
|
77
|
+
import type { OperationHandler } from '@happyvertical/smrt-ingestion/execution';
|
|
78
|
+
/** Build the authoritative discovery/review catalog for ONE authenticated
|
|
79
|
+
* learner/enrollment. The application resolves request from its session and
|
|
80
|
+
* assignment; model/browser output can select a lesson but cannot select a user.
|
|
81
|
+
* Keep all ingestion policy layers requireReview=true; no automation opt-in.
|
|
82
|
+
*/
|
|
83
|
+
export declare function educationIntakeHandlers(input: {
|
|
84
|
+
submissions: SubmissionService;
|
|
85
|
+
request: LearnerRequest & {
|
|
86
|
+
enrollmentId: string;
|
|
87
|
+
};
|
|
88
|
+
/** After capture, bind a new grading stage to this exact immutable attempt. */
|
|
89
|
+
submissionId?: string;
|
|
90
|
+
}): OperationHandler[];
|
|
91
|
+
/** Owning education target resolver for ingestion's transactional host hook.
|
|
92
|
+
* Serializes with education enrollment mutations, then performs public service
|
|
93
|
+
* authorization on the supplied executor. The host still owns live principal
|
|
94
|
+
* grant/revocation serialization in its execution authorize callback.
|
|
95
|
+
*/
|
|
96
|
+
export declare function educationIntakeTargetResolver(input: {
|
|
97
|
+
submissions: SubmissionService;
|
|
98
|
+
request: LearnerRequest & {
|
|
99
|
+
enrollmentId: string;
|
|
100
|
+
};
|
|
101
|
+
submissionId?: string;
|
|
102
|
+
}): import('@happyvertical/smrt-ingestion/execution').IntakeExecutionOptions['assertTarget'];
|
|
103
|
+
//# sourceMappingURL=ingestion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingestion.d.ts","sourceRoot":"","sources":["../src/ingestion.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EAErB,KAAK,gBAAgB,EACtB,MAAM,sCAAsC,CAAC;AAE9C,OAAO,EAAE,2BAA2B,EAAE,MAAM,0BAA0B,CAAC;AAEvE,oFAAoF;AACpF,MAAM,WAAW,gCAAgC;IAC/C,qBAAqB,EAAE,MAAM,CAAC;IAC9B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAC5C,SAAS,EAAE,gBAAgB,EAC3B,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,gCAAgC,EAC/C,YAAY,CAAC,EAAE,MAAM;;;GAOtB;AAED;;;;GAIG;AACH,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACpD,SAAS,EAAE,gBAAgB,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,iBAAiB,CAAC;IAC3B,UAAU,EAAE,gCAAgC,CAAC;CAC9C,GAAG,OAAO,CACP;IAAE,IAAI,EAAE,WAAW,GAAG,uBAAuB,GAAG,aAAa,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,mBAAmB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAClD,CAmCA;AAGD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAG3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAEV,iBAAiB,EAClB,MAAM,yBAAyB,CAAC;AAEjC,MAAM,MAAM,6BAA6B,GAAG,cAAc,GAAG;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,iBAAiB,GAAG,gBAAgB,CAAC;IAC3C,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,sBAAsB,CAAC,KAAK,EAAE;IAClD,SAAS,EAAE,gBAAgB,CAAC;IAC5B,KAAK,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IAChC,WAAW,EAAE,iBAAiB,CAAC;IAC/B,OAAO,EAAE,6BAA6B,CAAC;CACxC;;;;;;;;;;;;;;;;;;;;;IAyEA;AAED,OAAO,KAAK,EAGV,gBAAgB,EACjB,MAAM,yCAAyC,CAAC;AAuBjD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,WAAW,EAAE,iBAAiB,CAAC;IAC/B,OAAO,EAAE,cAAc,GAAG;QAAE,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,gBAAgB,EAAE,CAkOrB;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE;IACnD,WAAW,EAAE,iBAAiB,CAAC;IAC/B,OAAO,EAAE,cAAc,GAAG;QAAE,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,yCAAyC,EAAE,sBAAsB,CAAC,cAAc,CAAC,CA8C3F"}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { getCurrentTenant, withTenant } from "@happyvertical/smrt-tenancy";
|
|
2
|
+
import { registerPermissionDefinitions } from "@happyvertical/smrt-users";
|
|
3
|
+
import { extractAnalysis } from "@happyvertical/smrt-ingestion/server";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
//#region src/ingestion-isolation.ts
|
|
8
|
+
async function requireIngestionMemoryLimit(maximumBytes) {
|
|
9
|
+
if (process.platform !== "linux") throw new Error("Extraction requires a Linux cgroup-v2 memory-limited worker");
|
|
10
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) throw new Error("Invalid worker memory ceiling");
|
|
11
|
+
const entry = (await readFile("/proc/self/cgroup", "utf8")).split("\n").find((line) => line.startsWith("0::"));
|
|
12
|
+
if (!entry) throw new Error("No cgroup-v2 membership");
|
|
13
|
+
const relative = entry.slice(3);
|
|
14
|
+
if (!relative.startsWith("/") || relative.split("/").includes("..")) throw new Error("Invalid cgroup membership");
|
|
15
|
+
const root = "/sys/fs/cgroup";
|
|
16
|
+
let directory = resolve(root, `.${relative}`);
|
|
17
|
+
let limit = Number.POSITIVE_INFINITY;
|
|
18
|
+
while (directory === root || directory.startsWith(`${root}/`)) {
|
|
19
|
+
const value = (await readFile(`${directory}/memory.max`, "utf8")).trim();
|
|
20
|
+
if (value !== "max") {
|
|
21
|
+
if (!/^\d+$/.test(value)) throw new Error("Invalid cgroup memory limit");
|
|
22
|
+
const parsed = Number(value);
|
|
23
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error("Invalid cgroup memory limit");
|
|
24
|
+
limit = Math.min(limit, parsed);
|
|
25
|
+
}
|
|
26
|
+
if (directory === root) break;
|
|
27
|
+
directory = resolve(directory, "..");
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isFinite(limit) || limit > maximumBytes) throw new Error("Worker native-memory ceiling is missing or exceeds policy");
|
|
30
|
+
return { memoryLimitBytes: limit };
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/ingestion.ts
|
|
34
|
+
async function queueEducationExtraction(ingestion, itemId, configuration, submissionId) {
|
|
35
|
+
return ingestion.analyze(itemId, { extraction: configuration }, `education:extract:${configuration.configurationRevision}:${submissionId ?? "identify"}`);
|
|
36
|
+
}
|
|
37
|
+
async function processEducationAnalysis(input) {
|
|
38
|
+
const { ingestion, itemId } = input;
|
|
39
|
+
const lease = await ingestion.claimAnalysis(itemId, input.revision, input.workerId);
|
|
40
|
+
if (!lease) return { kind: "unclaimed" };
|
|
41
|
+
const snapshot = await ingestion.getAnalysisInput(lease);
|
|
42
|
+
if (snapshot.configuration.stage === "interpret") {
|
|
43
|
+
await ingestion.generateProposals(lease);
|
|
44
|
+
return { kind: "interpreted" };
|
|
45
|
+
}
|
|
46
|
+
if (!snapshot.configuration.extraction) throw new Error("Unsupported education analysis stage");
|
|
47
|
+
if (!await extractAnalysis(ingestion, lease, input.adapter, input.extraction)) return { kind: "extraction_incomplete" };
|
|
48
|
+
const result = await ingestion.getCompletedAnalysis(itemId, lease.attemptId);
|
|
49
|
+
if (!["completed", "partial"].includes(result.result.status)) return { kind: "extraction_incomplete" };
|
|
50
|
+
const generation = await ingestion.prepareGeneration(itemId, result.attemptId);
|
|
51
|
+
return {
|
|
52
|
+
kind: "generation_queued",
|
|
53
|
+
revision: (await ingestion.analyze(itemId, generation, `education:interpret:${result.attemptId}`)).revision
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function captureEducationIntake(input) {
|
|
57
|
+
const { ingestion, submissions, scope, request } = input;
|
|
58
|
+
if (request.tenantId !== scope.tenantId || request.actorUserId !== scope.actorId || request.enrollmentId !== scope.confidentialScopeId || !["blank_reference", "completed_work"].includes(request.kind) || request.kind === "blank_reference" && request.previousSubmissionId) throw new Error("Invalid education intake binding");
|
|
59
|
+
await submissions.assertLessonAssignment(request);
|
|
60
|
+
const evidence = (await ingestion.getEvidence(request.itemId)).filter((entry) => entry.parentEvidenceId === null);
|
|
61
|
+
const extensions = {
|
|
62
|
+
"image/jpeg": "jpg",
|
|
63
|
+
"image/png": "png",
|
|
64
|
+
"image/webp": "webp",
|
|
65
|
+
"application/pdf": "pdf"
|
|
66
|
+
};
|
|
67
|
+
if (!evidence.length || evidence.length > 100 || evidence.some((entry) => !extensions[entry.mediaType] || !Number.isSafeInteger(entry.byteLength) || entry.byteLength < 1 || entry.byteLength > 25 * 1024 * 1024) || evidence.reduce((sum, entry) => sum + entry.byteLength, 0) > 100 * 1024 * 1024) throw new Error("Unsupported or oversized education intake");
|
|
68
|
+
evidence.sort((a, b) => a.partId.localeCompare(b.partId));
|
|
69
|
+
const pages = [];
|
|
70
|
+
for (const entry of evidence) {
|
|
71
|
+
const bytes = await ingestion.readEvidence(request.itemId, entry.id);
|
|
72
|
+
if (bytes.byteLength !== entry.byteLength || createHash("sha256").update(bytes).digest("hex") !== entry.contentHash) throw new Error("Education intake integrity mismatch");
|
|
73
|
+
pages.push({
|
|
74
|
+
name: `${entry.id}.${extensions[entry.mediaType]}`,
|
|
75
|
+
mimeType: entry.mediaType,
|
|
76
|
+
data: Buffer.from(bytes)
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const capture = {
|
|
80
|
+
...request,
|
|
81
|
+
requestKey: `education:intake:${request.itemId}`,
|
|
82
|
+
pages,
|
|
83
|
+
intake: {
|
|
84
|
+
intakeItemId: request.itemId,
|
|
85
|
+
kind: request.kind,
|
|
86
|
+
previousSubmissionId: request.previousSubmissionId
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (request.kind === "blank_reference") return submissions.captureBlankReference({
|
|
90
|
+
...capture,
|
|
91
|
+
intakeItemId: request.itemId
|
|
92
|
+
});
|
|
93
|
+
return request.previousSubmissionId ? submissions.resubmitSubmission({
|
|
94
|
+
intakeItemId: request.itemId,
|
|
95
|
+
...capture,
|
|
96
|
+
previousSubmissionId: request.previousSubmissionId
|
|
97
|
+
}) : submissions.captureSubmission(capture);
|
|
98
|
+
}
|
|
99
|
+
var LESSON = "@happyvertical/smrt-education:Lesson";
|
|
100
|
+
var SUBMISSION = "@happyvertical/smrt-education:Submission";
|
|
101
|
+
var GRADE = "@happyvertical/smrt-education:Grade";
|
|
102
|
+
var idSchema = {
|
|
103
|
+
type: "string",
|
|
104
|
+
minLength: 1,
|
|
105
|
+
maxLength: 64
|
|
106
|
+
};
|
|
107
|
+
registerPermissionDefinitions([{
|
|
108
|
+
slug: "lessons.read",
|
|
109
|
+
collection: "lessons",
|
|
110
|
+
qualifiedName: LESSON,
|
|
111
|
+
name: "Identify authorized education assignments"
|
|
112
|
+
}, {
|
|
113
|
+
slug: "grades.create",
|
|
114
|
+
collection: "grades",
|
|
115
|
+
qualifiedName: GRADE,
|
|
116
|
+
name: "Apply guardian-reviewed education grades"
|
|
117
|
+
}]);
|
|
118
|
+
function educationIntakeHandlers(input) {
|
|
119
|
+
const { submissions, request } = input;
|
|
120
|
+
const bound = async (context, work) => {
|
|
121
|
+
if (context.scope.tenantId !== request.tenantId || context.scope.actorId !== request.actorUserId || !context.policy.requireReview) throw new Error("Education intake scope mismatch");
|
|
122
|
+
if (!context.principal) return submissions.withExecutor(context.db, work);
|
|
123
|
+
const tenant = getCurrentTenant();
|
|
124
|
+
if (!tenant || tenant.tenantId !== request.tenantId || tenant.userId !== context.principal.context.userId || context.principal.context.tenantId !== request.tenantId) throw new Error("Education principal tenant mismatch");
|
|
125
|
+
return withTenant({
|
|
126
|
+
...tenant,
|
|
127
|
+
tenantId: request.tenantId,
|
|
128
|
+
userId: request.actorUserId,
|
|
129
|
+
superAdminBypass: false
|
|
130
|
+
}, () => submissions.withExecutor(context.db, work));
|
|
131
|
+
};
|
|
132
|
+
const lessons = (context) => bound(context, (service) => service.assignmentLessons(request));
|
|
133
|
+
const requireLesson = async (args, context) => {
|
|
134
|
+
const found = (await lessons(context)).find((entry) => entry.lesson.id === args.lessonId);
|
|
135
|
+
if (!found) throw new Error("Education assignment unavailable");
|
|
136
|
+
await context.assertTarget(LESSON, found.lesson.id);
|
|
137
|
+
return found;
|
|
138
|
+
};
|
|
139
|
+
const handlers = [{
|
|
140
|
+
id: "@happyvertical/smrt-education:identify-assignment",
|
|
141
|
+
version: "1",
|
|
142
|
+
description: "Identify an assigned lesson from a blank or completed scan. Ambiguity requires guardian correction; this action does not grade or capture.",
|
|
143
|
+
operation: {
|
|
144
|
+
model: LESSON,
|
|
145
|
+
action: "get",
|
|
146
|
+
version: "1"
|
|
147
|
+
},
|
|
148
|
+
capability: {
|
|
149
|
+
effect: "read",
|
|
150
|
+
idempotent: true,
|
|
151
|
+
openWorld: false
|
|
152
|
+
},
|
|
153
|
+
argsSchema: {
|
|
154
|
+
type: "object",
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
required: ["lessonId"],
|
|
157
|
+
properties: { lessonId: idSchema }
|
|
158
|
+
},
|
|
159
|
+
resultSchema: {
|
|
160
|
+
type: "object",
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
required: ["lessonId"],
|
|
163
|
+
properties: { lessonId: idSchema }
|
|
164
|
+
},
|
|
165
|
+
resultModels: { lessonId: LESSON },
|
|
166
|
+
discovery: {
|
|
167
|
+
mediaTypes: [
|
|
168
|
+
"image/jpeg",
|
|
169
|
+
"image/png",
|
|
170
|
+
"image/webp",
|
|
171
|
+
"application/pdf"
|
|
172
|
+
],
|
|
173
|
+
references: { lessonId: {
|
|
174
|
+
kind: "candidate",
|
|
175
|
+
model: LESSON
|
|
176
|
+
} },
|
|
177
|
+
candidates: async (query, context, page) => {
|
|
178
|
+
const visible = await lessons(context);
|
|
179
|
+
const needle = query.toLocaleLowerCase();
|
|
180
|
+
const ordered = [...visible].sort((a, b) => Number(b.lesson.title.toLocaleLowerCase().includes(needle)) - Number(a.lesson.title.toLocaleLowerCase().includes(needle)) || a.lesson.id.localeCompare(b.lesson.id));
|
|
181
|
+
return {
|
|
182
|
+
items: ordered.slice(0, page.limit).map((entry) => ({
|
|
183
|
+
model: LESSON,
|
|
184
|
+
id: entry.lesson.id,
|
|
185
|
+
revision: `assignment:${request.enrollmentId}:${entry.lesson.id}`,
|
|
186
|
+
label: entry.lesson.title
|
|
187
|
+
})),
|
|
188
|
+
hasMore: ordered.length > page.limit
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
validate: async (args, context) => {
|
|
193
|
+
await requireLesson(args, context);
|
|
194
|
+
return { ok: true };
|
|
195
|
+
},
|
|
196
|
+
preview: async (args, context) => {
|
|
197
|
+
const found = await requireLesson(args, context);
|
|
198
|
+
return {
|
|
199
|
+
display: {
|
|
200
|
+
lesson: found.lesson.title,
|
|
201
|
+
gradeApplied: false
|
|
202
|
+
},
|
|
203
|
+
normalizedArgs: args,
|
|
204
|
+
targetPreconditions: [{
|
|
205
|
+
model: LESSON,
|
|
206
|
+
id: found.lesson.id,
|
|
207
|
+
revision: `assignment:${request.enrollmentId}:${found.lesson.id}`
|
|
208
|
+
}]
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
execution: {
|
|
212
|
+
kind: "database",
|
|
213
|
+
apply: async (args, context) => {
|
|
214
|
+
return { lessonId: (await requireLesson(args, context)).lesson.id };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}];
|
|
218
|
+
if (!input.submissionId) return handlers;
|
|
219
|
+
const submissionId = input.submissionId;
|
|
220
|
+
const current = (context) => bound(context, (service) => service.getSubmission({
|
|
221
|
+
...request,
|
|
222
|
+
submissionId
|
|
223
|
+
}));
|
|
224
|
+
const requireSubmission = async (args, context) => {
|
|
225
|
+
if (args.submissionId !== submissionId) throw new Error("Education attempt binding mismatch");
|
|
226
|
+
const submission = await current(context);
|
|
227
|
+
if (submission.intakeItemId !== context.itemId || submission.kind !== "completed_work") throw new Error("Education intake/attempt binding mismatch");
|
|
228
|
+
await context.assertTarget(SUBMISSION, submissionId);
|
|
229
|
+
return submission;
|
|
230
|
+
};
|
|
231
|
+
handlers.push({
|
|
232
|
+
id: "@happyvertical/smrt-education:review-submission",
|
|
233
|
+
version: "1",
|
|
234
|
+
description: "Suggest marks and feedback for this captured attempt. Only a current authorized guardian decision may apply a grade.",
|
|
235
|
+
operation: {
|
|
236
|
+
model: GRADE,
|
|
237
|
+
action: "create",
|
|
238
|
+
version: "1"
|
|
239
|
+
},
|
|
240
|
+
capability: {
|
|
241
|
+
effect: "write",
|
|
242
|
+
idempotent: false,
|
|
243
|
+
openWorld: false
|
|
244
|
+
},
|
|
245
|
+
argsSchema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
required: [
|
|
249
|
+
"submissionId",
|
|
250
|
+
"score",
|
|
251
|
+
"feedback"
|
|
252
|
+
],
|
|
253
|
+
properties: {
|
|
254
|
+
submissionId: idSchema,
|
|
255
|
+
score: {
|
|
256
|
+
type: ["number", "null"],
|
|
257
|
+
minimum: 0,
|
|
258
|
+
maximum: 1
|
|
259
|
+
},
|
|
260
|
+
feedback: {
|
|
261
|
+
type: "string",
|
|
262
|
+
maxLength: 1e4
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
resultSchema: {
|
|
267
|
+
type: "object",
|
|
268
|
+
additionalProperties: false,
|
|
269
|
+
required: ["submissionId"],
|
|
270
|
+
properties: { submissionId: idSchema }
|
|
271
|
+
},
|
|
272
|
+
resultModels: { submissionId: SUBMISSION },
|
|
273
|
+
discovery: {
|
|
274
|
+
mediaTypes: [
|
|
275
|
+
"image/jpeg",
|
|
276
|
+
"image/png",
|
|
277
|
+
"image/webp",
|
|
278
|
+
"application/pdf"
|
|
279
|
+
],
|
|
280
|
+
references: { submissionId: {
|
|
281
|
+
kind: "candidate",
|
|
282
|
+
model: SUBMISSION
|
|
283
|
+
} },
|
|
284
|
+
candidates: async (_query, context) => {
|
|
285
|
+
const submission = await current(context);
|
|
286
|
+
return {
|
|
287
|
+
items: [{
|
|
288
|
+
model: SUBMISSION,
|
|
289
|
+
id: submission.id,
|
|
290
|
+
revision: `attempt:${submission.id}:${submission.status}`,
|
|
291
|
+
label: "Current captured attempt (guardian review required)"
|
|
292
|
+
}],
|
|
293
|
+
hasMore: false
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
validate: async (args, context) => {
|
|
298
|
+
await requireSubmission(args, context);
|
|
299
|
+
return { ok: true };
|
|
300
|
+
},
|
|
301
|
+
preview: async (args, context) => {
|
|
302
|
+
const submission = await requireSubmission(args, context);
|
|
303
|
+
return {
|
|
304
|
+
display: {
|
|
305
|
+
submissionId,
|
|
306
|
+
proposedScore: args.score,
|
|
307
|
+
proposedFeedback: args.feedback,
|
|
308
|
+
guardianReviewRequired: true
|
|
309
|
+
},
|
|
310
|
+
normalizedArgs: args,
|
|
311
|
+
targetPreconditions: [{
|
|
312
|
+
model: SUBMISSION,
|
|
313
|
+
id: submissionId,
|
|
314
|
+
revision: `attempt:${submission.id}:${submission.status}`
|
|
315
|
+
}]
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
execution: {
|
|
319
|
+
kind: "database",
|
|
320
|
+
apply: async (args, context) => {
|
|
321
|
+
await requireSubmission(args, context);
|
|
322
|
+
await bound(context, (service) => service.applyReviewedSubmission({
|
|
323
|
+
...request,
|
|
324
|
+
submissionId,
|
|
325
|
+
score: args.score,
|
|
326
|
+
feedback: String(args.feedback)
|
|
327
|
+
}));
|
|
328
|
+
return { submissionId };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return handlers;
|
|
333
|
+
}
|
|
334
|
+
function educationIntakeTargetResolver(input) {
|
|
335
|
+
return async ({ db, scope, itemId, model, id, revision }) => {
|
|
336
|
+
const { request, submissions } = input;
|
|
337
|
+
if (scope.tenantId !== request.tenantId || scope.actorId !== request.actorUserId) throw new Error("Education intake scope mismatch");
|
|
338
|
+
await db.query("UPDATE enrollments SET id=id WHERE id=? AND tenant_id=?", request.enrollmentId, request.tenantId);
|
|
339
|
+
const current = await withTenant({
|
|
340
|
+
...getCurrentTenant(),
|
|
341
|
+
tenantId: request.tenantId,
|
|
342
|
+
userId: request.actorUserId,
|
|
343
|
+
superAdminBypass: false
|
|
344
|
+
}, () => submissions.withExecutor(db, async (service) => {
|
|
345
|
+
if (model === LESSON) {
|
|
346
|
+
if (!(await service.assignmentLessons(request)).some((entry) => entry.lesson.id === id)) throw new Error("Education assignment unavailable");
|
|
347
|
+
return `assignment:${request.enrollmentId}:${id}`;
|
|
348
|
+
}
|
|
349
|
+
if (model !== SUBMISSION || id !== input.submissionId) throw new Error("Education target unavailable");
|
|
350
|
+
const submission = await service.getSubmission({
|
|
351
|
+
...request,
|
|
352
|
+
submissionId: id
|
|
353
|
+
});
|
|
354
|
+
if (submission.intakeItemId !== itemId || submission.kind !== "completed_work") throw new Error("Education intake/attempt binding mismatch");
|
|
355
|
+
return `attempt:${submission.id}:${submission.status}`;
|
|
356
|
+
}));
|
|
357
|
+
if (revision !== void 0 && revision !== current) throw new Error("Education target changed");
|
|
358
|
+
return { revision: current };
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
//#endregion
|
|
362
|
+
export { captureEducationIntake, educationIntakeHandlers, educationIntakeTargetResolver, processEducationAnalysis, queueEducationExtraction, requireIngestionMemoryLimit };
|
|
363
|
+
|
|
364
|
+
//# sourceMappingURL=ingestion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingestion.js","names":[],"sources":["../src/ingestion-isolation.ts","../src/ingestion.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport { resolve } from 'node:path';\n\n/** Verify a real Linux cgroup-v2 native-memory ceiling before enabling decoders.\n * The worker and its children must remain in this cgroup; deployments must deny\n * cgroup writes/escape and run without privileged container capabilities.\n */\nexport async function requireIngestionMemoryLimit(\n maximumBytes: number,\n): Promise<{ memoryLimitBytes: number }> {\n if (process.platform !== 'linux')\n throw new Error(\n 'Extraction requires a Linux cgroup-v2 memory-limited worker',\n );\n if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)\n throw new Error('Invalid worker memory ceiling');\n const membership = await readFile('/proc/self/cgroup', 'utf8');\n const entry = membership.split('\\n').find((line) => line.startsWith('0::'));\n if (!entry) throw new Error('No cgroup-v2 membership');\n const relative = entry.slice(3);\n if (!relative.startsWith('/') || relative.split('/').includes('..'))\n throw new Error('Invalid cgroup membership');\n const root = '/sys/fs/cgroup';\n let directory = resolve(root, `.${relative}`);\n let limit = Number.POSITIVE_INFINITY;\n // An ancestor limit is authoritative too. Account for inherited limits while\n // never accepting a mere configuration promise or Node heap setting.\n while (directory === root || directory.startsWith(`${root}/`)) {\n const value = (await readFile(`${directory}/memory.max`, 'utf8')).trim();\n if (value !== 'max') {\n if (!/^\\d+$/.test(value)) throw new Error('Invalid cgroup memory limit');\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 1)\n throw new Error('Invalid cgroup memory limit');\n limit = Math.min(limit, parsed);\n }\n if (directory === root) break;\n directory = resolve(directory, '..');\n }\n if (!Number.isFinite(limit) || limit > maximumBytes)\n throw new Error(\n 'Worker native-memory ceiling is missing or exceeds policy',\n );\n return { memoryLimitBytes: limit };\n}\n","/** Server-only paper workflow integration. Never import from browser handlers. */\nimport {\n type ExtractionAdapter,\n type ExtractionLimits,\n extractAnalysis,\n type IngestionService,\n} from '@happyvertical/smrt-ingestion/server';\n\nexport { requireIngestionMemoryLimit } from './ingestion-isolation.js';\n\n/** Immutable operator configuration; never populated from upload/model metadata. */\nexport interface EducationExtractionConfiguration {\n configurationRevision: string;\n limits: ExtractionLimits;\n}\n\n/** Allocate extraction after a durable receipt. The normal ingestion outbox\n * dispatches this revision to the application's registered job target.\n */\nexport async function queueEducationExtraction(\n ingestion: IngestionService,\n itemId: string,\n configuration: EducationExtractionConfiguration,\n submissionId?: string,\n) {\n return ingestion.analyze(\n itemId,\n { extraction: configuration },\n `education:extract:${configuration.configurationRevision}:${submissionId ?? 'identify'}`,\n );\n}\n\n/** A registered job invokes this with its persisted item/revision IDs and an\n * authenticated service scope. Providers execute outside database transactions.\n * Interpretation receives ingestion's immutable extraction pin. Neither stage\n * approves a suggestion, captures a submission, or applies a grade.\n */\nexport async function processEducationAnalysis(input: {\n ingestion: IngestionService;\n itemId: string;\n revision: number;\n workerId: string;\n adapter: ExtractionAdapter;\n extraction: EducationExtractionConfiguration;\n}): Promise<\n | { kind: 'unclaimed' | 'extraction_incomplete' | 'interpreted' }\n | { kind: 'generation_queued'; revision: number }\n> {\n const { ingestion, itemId } = input;\n const lease = await ingestion.claimAnalysis(\n itemId,\n input.revision,\n input.workerId,\n );\n if (!lease) return { kind: 'unclaimed' };\n const snapshot = await ingestion.getAnalysisInput(lease);\n if (snapshot.configuration.stage === 'interpret') {\n await ingestion.generateProposals(lease);\n return { kind: 'interpreted' };\n }\n if (!snapshot.configuration.extraction)\n throw new Error('Unsupported education analysis stage');\n const completed = await extractAnalysis(\n ingestion,\n lease,\n input.adapter,\n input.extraction,\n );\n if (!completed) return { kind: 'extraction_incomplete' };\n const result = await ingestion.getCompletedAnalysis(itemId, lease.attemptId);\n if (!['completed', 'partial'].includes(result.result.status))\n return { kind: 'extraction_incomplete' };\n const generation = await ingestion.prepareGeneration(\n itemId,\n result.attemptId,\n );\n const queued = await ingestion.analyze(\n itemId,\n generation,\n `education:interpret:${result.attemptId}`,\n );\n return { kind: 'generation_queued', revision: queued.revision };\n}\n\nimport { createHash } from 'node:crypto';\nimport type { IngestionScope } from '@happyvertical/smrt-ingestion/server';\nimport { getCurrentTenant, withTenant } from '@happyvertical/smrt-tenancy';\nimport { registerPermissionDefinitions } from '@happyvertical/smrt-users';\nimport type { LearnerRequest } from './learner-service.js';\nimport type {\n SubmissionPage,\n SubmissionService,\n} from './submission-service.js';\n\nexport type EducationIntakeCaptureRequest = LearnerRequest & {\n enrollmentId: string;\n lessonId: string;\n itemId: string;\n kind: 'blank_reference' | 'completed_work';\n previousSubmissionId?: string;\n notes?: string;\n};\n\n/** Copy immutable retained originals into education's private ownership only\n * after the host has obtained explicit assignment confirmation. Supply service\n * and scope from the SAME authenticated server factory, never browser values.\n * The owning service persists unique tenant/item binding before allowing retries.\n */\nexport async function captureEducationIntake(input: {\n ingestion: IngestionService;\n scope: Readonly<IngestionScope>;\n submissions: SubmissionService;\n request: EducationIntakeCaptureRequest;\n}) {\n const { ingestion, submissions, scope, request } = input;\n if (\n request.tenantId !== scope.tenantId ||\n request.actorUserId !== scope.actorId ||\n request.enrollmentId !== scope.confidentialScopeId ||\n !['blank_reference', 'completed_work'].includes(request.kind) ||\n (request.kind === 'blank_reference' && request.previousSubmissionId)\n )\n throw new Error('Invalid education intake binding');\n // Prove the exact active learner/enrollment/lesson assignment before reading\n // any family-private retained evidence. Intake scopes are enrollment-bound.\n await submissions.assertLessonAssignment(request);\n const evidence = (await ingestion.getEvidence(request.itemId)).filter(\n (entry) => entry.parentEvidenceId === null,\n );\n const extensions: Record<string, string> = {\n 'image/jpeg': 'jpg',\n 'image/png': 'png',\n 'image/webp': 'webp',\n 'application/pdf': 'pdf',\n };\n if (\n !evidence.length ||\n evidence.length > 100 ||\n evidence.some(\n (entry) =>\n !extensions[entry.mediaType] ||\n !Number.isSafeInteger(entry.byteLength) ||\n entry.byteLength < 1 ||\n entry.byteLength > 25 * 1024 * 1024,\n ) ||\n evidence.reduce((sum, entry) => sum + entry.byteLength, 0) >\n 100 * 1024 * 1024\n )\n throw new Error('Unsupported or oversized education intake');\n evidence.sort((a, b) => a.partId.localeCompare(b.partId));\n const pages: SubmissionPage[] = [];\n for (const entry of evidence) {\n const bytes = await ingestion.readEvidence(request.itemId, entry.id);\n if (\n bytes.byteLength !== entry.byteLength ||\n createHash('sha256').update(bytes).digest('hex') !== entry.contentHash\n )\n throw new Error('Education intake integrity mismatch');\n pages.push({\n name: `${entry.id}.${extensions[entry.mediaType]}`,\n mimeType: entry.mediaType as SubmissionPage['mimeType'],\n data: Buffer.from(bytes),\n });\n }\n const capture = {\n ...request,\n requestKey: `education:intake:${request.itemId}`,\n pages,\n intake: {\n intakeItemId: request.itemId,\n kind: request.kind,\n previousSubmissionId: request.previousSubmissionId,\n },\n };\n if (request.kind === 'blank_reference')\n return submissions.captureBlankReference({\n ...capture,\n intakeItemId: request.itemId,\n });\n return request.previousSubmissionId\n ? submissions.resubmitSubmission({\n intakeItemId: request.itemId,\n ...capture,\n previousSubmissionId: request.previousSubmissionId,\n })\n : submissions.captureSubmission(capture);\n}\n\nimport type {\n HandlerContext,\n IntakeValues,\n OperationHandler,\n} from '@happyvertical/smrt-ingestion/execution';\n\nconst LESSON = '@happyvertical/smrt-education:Lesson';\nconst SUBMISSION = '@happyvertical/smrt-education:Submission';\nconst GRADE = '@happyvertical/smrt-education:Grade';\nconst idSchema = { type: 'string', minLength: 1, maxLength: 64 };\n// Closed model transports intentionally generate no operation permissions.\n// Declaring this server catalog does not grant it to any principal or actor.\nregisterPermissionDefinitions([\n {\n slug: 'lessons.read',\n collection: 'lessons',\n qualifiedName: LESSON,\n name: 'Identify authorized education assignments',\n },\n {\n slug: 'grades.create',\n collection: 'grades',\n qualifiedName: GRADE,\n name: 'Apply guardian-reviewed education grades',\n },\n]);\n\n/** Build the authoritative discovery/review catalog for ONE authenticated\n * learner/enrollment. The application resolves request from its session and\n * assignment; model/browser output can select a lesson but cannot select a user.\n * Keep all ingestion policy layers requireReview=true; no automation opt-in.\n */\nexport function educationIntakeHandlers(input: {\n submissions: SubmissionService;\n request: LearnerRequest & { enrollmentId: string };\n /** After capture, bind a new grading stage to this exact immutable attempt. */\n submissionId?: string;\n}): OperationHandler[] {\n const { submissions, request } = input;\n const bound = async <T>(\n context: HandlerContext,\n work: (service: SubmissionService) => Promise<T>,\n ): Promise<T> => {\n if (\n context.scope.tenantId !== request.tenantId ||\n context.scope.actorId !== request.actorUserId ||\n !context.policy.requireReview\n )\n throw new Error('Education intake scope mismatch');\n // Ingestion's stable execution principal is not the human requester.\n // Preserve tenant permissions and the separate PrincipalRun runtime; only\n // the domain actor changes to the already-authenticated scope actor.\n if (!context.principal) return submissions.withExecutor(context.db, work);\n const tenant = getCurrentTenant();\n if (\n !tenant ||\n tenant.tenantId !== request.tenantId ||\n tenant.userId !== context.principal.context.userId ||\n context.principal.context.tenantId !== request.tenantId\n )\n throw new Error('Education principal tenant mismatch');\n return withTenant(\n {\n ...tenant,\n tenantId: request.tenantId,\n userId: request.actorUserId,\n superAdminBypass: false,\n },\n () => submissions.withExecutor(context.db, work),\n );\n };\n const lessons = (context: HandlerContext) =>\n bound(context, (service) => service.assignmentLessons(request));\n const requireLesson = async (args: IntakeValues, context: HandlerContext) => {\n const found = (await lessons(context)).find(\n (entry) => entry.lesson.id === args.lessonId,\n );\n if (!found) throw new Error('Education assignment unavailable');\n await context.assertTarget(LESSON, found.lesson.id);\n return found;\n };\n const handlers: OperationHandler[] = [\n {\n id: '@happyvertical/smrt-education:identify-assignment',\n version: '1',\n description:\n 'Identify an assigned lesson from a blank or completed scan. Ambiguity requires guardian correction; this action does not grade or capture.',\n operation: { model: LESSON, action: 'get', version: '1' },\n capability: { effect: 'read', idempotent: true, openWorld: false },\n argsSchema: {\n type: 'object',\n additionalProperties: false,\n required: ['lessonId'],\n properties: { lessonId: idSchema },\n },\n resultSchema: {\n type: 'object',\n additionalProperties: false,\n required: ['lessonId'],\n properties: { lessonId: idSchema },\n },\n resultModels: { lessonId: LESSON },\n discovery: {\n mediaTypes: [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'application/pdf',\n ],\n references: { lessonId: { kind: 'candidate', model: LESSON } },\n candidates: async (query, context, page) => {\n // Authorize/filter exact enrollment FIRST; text only ranks that set.\n const visible = await lessons(context);\n const needle = query.toLocaleLowerCase();\n const ordered = [...visible].sort(\n (a, b) =>\n Number(b.lesson.title.toLocaleLowerCase().includes(needle)) -\n Number(a.lesson.title.toLocaleLowerCase().includes(needle)) ||\n a.lesson.id.localeCompare(b.lesson.id),\n );\n return {\n items: ordered.slice(0, page.limit).map((entry) => ({\n model: LESSON,\n id: entry.lesson.id,\n // Version binds immutable assignment identity; the host target\n // resolver must use this same format and reauthorize the plan.\n revision: `assignment:${request.enrollmentId}:${entry.lesson.id}`,\n label: entry.lesson.title,\n })),\n hasMore: ordered.length > page.limit,\n };\n },\n },\n validate: async (args, context) => {\n await requireLesson(args, context);\n return { ok: true };\n },\n preview: async (args, context) => {\n const found = await requireLesson(args, context);\n return {\n display: { lesson: found.lesson.title, gradeApplied: false },\n normalizedArgs: args,\n targetPreconditions: [\n {\n model: LESSON,\n id: found.lesson.id,\n revision: `assignment:${request.enrollmentId}:${found.lesson.id}`,\n },\n ],\n };\n },\n execution: {\n kind: 'database',\n apply: async (args, context) => {\n const found = await requireLesson(args, context);\n return { lessonId: found.lesson.id };\n },\n },\n },\n ];\n if (!input.submissionId) return handlers;\n const submissionId = input.submissionId;\n const current = (context: HandlerContext) =>\n bound(context, (service) =>\n service.getSubmission({ ...request, submissionId }),\n );\n const requireSubmission = async (\n args: IntakeValues,\n context: HandlerContext,\n ) => {\n if (args.submissionId !== submissionId)\n throw new Error('Education attempt binding mismatch');\n const submission = await current(context);\n if (\n submission.intakeItemId !== context.itemId ||\n submission.kind !== 'completed_work'\n )\n throw new Error('Education intake/attempt binding mismatch');\n await context.assertTarget(SUBMISSION, submissionId);\n return submission;\n };\n handlers.push({\n id: '@happyvertical/smrt-education:review-submission',\n version: '1',\n description:\n 'Suggest marks and feedback for this captured attempt. Only a current authorized guardian decision may apply a grade.',\n operation: { model: GRADE, action: 'create', version: '1' },\n capability: { effect: 'write', idempotent: false, openWorld: false },\n argsSchema: {\n type: 'object',\n additionalProperties: false,\n required: ['submissionId', 'score', 'feedback'],\n properties: {\n submissionId: idSchema,\n score: { type: ['number', 'null'], minimum: 0, maximum: 1 },\n feedback: { type: 'string', maxLength: 10000 },\n },\n },\n resultSchema: {\n type: 'object',\n additionalProperties: false,\n required: ['submissionId'],\n properties: { submissionId: idSchema },\n },\n resultModels: { submissionId: SUBMISSION },\n discovery: {\n mediaTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],\n references: { submissionId: { kind: 'candidate', model: SUBMISSION } },\n candidates: async (_query, context) => {\n const submission = await current(context);\n return {\n items: [\n {\n model: SUBMISSION,\n id: submission.id,\n revision: `attempt:${submission.id}:${submission.status}`,\n label: 'Current captured attempt (guardian review required)',\n },\n ],\n hasMore: false,\n };\n },\n },\n validate: async (args, context) => {\n await requireSubmission(args, context);\n return { ok: true };\n },\n preview: async (args, context) => {\n const submission = await requireSubmission(args, context);\n return {\n display: {\n submissionId,\n proposedScore: args.score,\n proposedFeedback: args.feedback,\n guardianReviewRequired: true,\n },\n normalizedArgs: args,\n targetPreconditions: [\n {\n model: SUBMISSION,\n id: submissionId,\n revision: `attempt:${submission.id}:${submission.status}`,\n },\n ],\n };\n },\n execution: {\n kind: 'database',\n apply: async (args, context) => {\n await requireSubmission(args, context);\n await bound(context, (service) =>\n service.applyReviewedSubmission({\n ...request,\n submissionId,\n score: args.score as number | null,\n feedback: String(args.feedback),\n }),\n );\n return { submissionId };\n },\n },\n });\n return handlers;\n}\n\n/** Owning education target resolver for ingestion's transactional host hook.\n * Serializes with education enrollment mutations, then performs public service\n * authorization on the supplied executor. The host still owns live principal\n * grant/revocation serialization in its execution authorize callback.\n */\nexport function educationIntakeTargetResolver(input: {\n submissions: SubmissionService;\n request: LearnerRequest & { enrollmentId: string };\n submissionId?: string;\n}): import('@happyvertical/smrt-ingestion/execution').IntakeExecutionOptions['assertTarget'] {\n return async ({ db, scope, itemId, model, id, revision }) => {\n const { request, submissions } = input;\n if (\n scope.tenantId !== request.tenantId ||\n scope.actorId !== request.actorUserId\n )\n throw new Error('Education intake scope mismatch');\n await db.query(\n 'UPDATE enrollments SET id=id WHERE id=? AND tenant_id=?',\n request.enrollmentId,\n request.tenantId,\n );\n const current = await withTenant(\n {\n ...getCurrentTenant(),\n tenantId: request.tenantId,\n userId: request.actorUserId,\n superAdminBypass: false,\n },\n () =>\n submissions.withExecutor(db, async (service) => {\n if (model === LESSON) {\n const available = await service.assignmentLessons(request);\n if (!available.some((entry) => entry.lesson.id === id))\n throw new Error('Education assignment unavailable');\n return `assignment:${request.enrollmentId}:${id}`;\n }\n if (model !== SUBMISSION || id !== input.submissionId)\n throw new Error('Education target unavailable');\n const submission = await service.getSubmission({\n ...request,\n submissionId: id,\n });\n if (\n submission.intakeItemId !== itemId ||\n submission.kind !== 'completed_work'\n )\n throw new Error('Education intake/attempt binding mismatch');\n return `attempt:${submission.id}:${submission.status}`;\n }),\n );\n if (revision !== undefined && revision !== current)\n throw new Error('Education target changed');\n return { revision: current };\n };\n}\n"],"mappings":"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"}
|
|
@@ -8,11 +8,24 @@ export interface LearnerRequest {
|
|
|
8
8
|
learnerId: string;
|
|
9
9
|
guardianRelationshipId?: string;
|
|
10
10
|
}
|
|
11
|
+
/** A host-owned onboarding probe; it exposes no learner or relationship data. */
|
|
12
|
+
export interface LearnerSetupStatusRequest {
|
|
13
|
+
tenantId: string;
|
|
14
|
+
actorUserId: string;
|
|
15
|
+
learnerUserId: string;
|
|
16
|
+
}
|
|
17
|
+
export type LearnerSetupStatus = Readonly<{
|
|
18
|
+
needsSetup: boolean;
|
|
19
|
+
}>;
|
|
11
20
|
/** The host validates relationship type/terms and its own permission policy. */
|
|
12
21
|
export type LearnerAuthorizer = (request: LearnerRequest & {
|
|
13
|
-
|
|
22
|
+
/** The active service executor; host policy reads must share its transaction. */
|
|
23
|
+
executor: DatabaseInterface;
|
|
24
|
+
action: 'education.learner.read' | 'education.learner.plan' | 'education.learner.start' | 'education.learner.submit' | 'education.learner.grade' | 'education.learner.packet' | 'education.learner.setup';
|
|
14
25
|
own: boolean;
|
|
15
26
|
relationshipTypeId?: string;
|
|
27
|
+
/** Present only for the setup-status probe; it is not a relationship grant. */
|
|
28
|
+
learnerUserId?: string;
|
|
16
29
|
}) => boolean | Promise<boolean>;
|
|
17
30
|
export declare class LearnerAuthorizationError extends Error {
|
|
18
31
|
constructor();
|
|
@@ -61,9 +74,16 @@ export declare class LearnerService {
|
|
|
61
74
|
private writeFailure;
|
|
62
75
|
private writeTail;
|
|
63
76
|
private postgres;
|
|
77
|
+
/** A trusted host transaction; capture/storage is forbidden on this scope. */
|
|
78
|
+
protected executorBorrowed: boolean;
|
|
64
79
|
constructor(db: DatabaseInterface, authorize: LearnerAuthorizer, authorizeCatalog: CatalogAuthorizer);
|
|
65
80
|
/** Compose planning/start operations atomically using the callback's service. */
|
|
66
81
|
withTransaction<T>(operation: (service: this) => Promise<T>): Promise<T>;
|
|
82
|
+
/**
|
|
83
|
+
* Compose database-only education operations with a host-owned transaction.
|
|
84
|
+
* The callback service is closed when it returns and never opens a nested unit.
|
|
85
|
+
*/
|
|
86
|
+
withExecutor<T>(executor: DatabaseInterface, operation: (service: this) => Promise<T>): Promise<T>;
|
|
67
87
|
/** Internal subclass hook: preserve the concrete owning service in a transaction. */
|
|
68
88
|
protected createTransactionService(db: DatabaseInterface): this;
|
|
69
89
|
/** Internal compensation hook, invoked after in-flight work drains on abort. */
|
|
@@ -71,6 +91,28 @@ export declare class LearnerService {
|
|
|
71
91
|
protected mutate<T>(operation: (service: this) => Promise<T>): Promise<T>;
|
|
72
92
|
protected requireActor(request: LearnerRequest, action: Parameters<LearnerAuthorizer>[0]['action']): Promise<Learner>;
|
|
73
93
|
getLearner(request: LearnerRequest): Promise<LearnerView>;
|
|
94
|
+
/**
|
|
95
|
+
* Checks whether an active tenant member still needs a Learner row.
|
|
96
|
+
* The host authorizer decides who may run this onboarding probe; no learner
|
|
97
|
+
* metadata or guardian relationship is returned or created here.
|
|
98
|
+
*/
|
|
99
|
+
getLearnerSetupStatus(request: LearnerSetupStatusRequest): Promise<LearnerSetupStatus>;
|
|
100
|
+
/** Resolve the authenticated learner identity without exposing collections. */
|
|
101
|
+
getOwnLearner(request: Pick<LearnerRequest, 'tenantId' | 'actorUserId'>): Promise<LearnerView | null>;
|
|
102
|
+
/** Resolve a relationship-selected Person Profile through the normal guard. */
|
|
103
|
+
getLearnerForProfile(request: Pick<LearnerRequest, 'tenantId' | 'actorUserId' | 'guardianRelationshipId'> & {
|
|
104
|
+
learnerProfileId: string;
|
|
105
|
+
}): Promise<LearnerView>;
|
|
106
|
+
/**
|
|
107
|
+
* Trusted host provisioning after it has created the User, Person Profile and
|
|
108
|
+
* active Membership. It never creates credentials or relationship grants.
|
|
109
|
+
*/
|
|
110
|
+
provisionLearner(request: Omit<LearnerRequest, 'learnerId'> & {
|
|
111
|
+
learnerUserId: string;
|
|
112
|
+
learnerProfileId: string;
|
|
113
|
+
displayName: string;
|
|
114
|
+
level?: number;
|
|
115
|
+
}): Promise<LearnerView>;
|
|
74
116
|
createCurriculum(request: LearnerRequest & {
|
|
75
117
|
title: string;
|
|
76
118
|
}): Promise<CurriculumView>;
|
|
@@ -86,6 +128,37 @@ export declare class LearnerService {
|
|
|
86
128
|
listProgress(request: LearnerRequest & {
|
|
87
129
|
enrollmentId: string;
|
|
88
130
|
}): Promise<LessonProgressView[]>;
|
|
131
|
+
/** All exact-tenant enrollments visible to this learner or authorized guardian. */
|
|
132
|
+
listEnrollments(request: LearnerRequest): Promise<EnrollmentView[]>;
|
|
133
|
+
/**
|
|
134
|
+
* Projects the first unfinished lesson in each authorized enrollment. No
|
|
135
|
+
* catalog assets, answer material, model handles, or source locators escape.
|
|
136
|
+
*/
|
|
137
|
+
nextLessons(request: LearnerRequest & {
|
|
138
|
+
enrollmentId?: string;
|
|
139
|
+
}): Promise<ReadonlyArray<{
|
|
140
|
+
enrollment: EnrollmentView;
|
|
141
|
+
progress: LessonProgressView | null;
|
|
142
|
+
lesson: {
|
|
143
|
+
id: string;
|
|
144
|
+
title: string;
|
|
145
|
+
kind: string;
|
|
146
|
+
requiresOnlineResource: boolean;
|
|
147
|
+
};
|
|
148
|
+
}>>;
|
|
149
|
+
/** All lesson summaries for one authorized assignment/enrollment. */
|
|
150
|
+
assignmentLessons(request: LearnerRequest & {
|
|
151
|
+
enrollmentId: string;
|
|
152
|
+
}): Promise<ReadonlyArray<{
|
|
153
|
+
enrollment: EnrollmentView;
|
|
154
|
+
progress: LessonProgressView | null;
|
|
155
|
+
lesson: {
|
|
156
|
+
id: string;
|
|
157
|
+
title: string;
|
|
158
|
+
kind: string;
|
|
159
|
+
requiresOnlineResource: boolean;
|
|
160
|
+
};
|
|
161
|
+
}>>;
|
|
89
162
|
/** Start once. Caller-supplied learner/status/score/counter patches are never applied. */
|
|
90
163
|
startLesson(request: LearnerRequest & {
|
|
91
164
|
enrollmentId: string;
|