@happyvertical/smrt-education 0.57.37 → 0.57.39
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 +8 -0
- package/agents/catalog-previews.md +98 -0
- package/agents/family-intake.md +97 -0
- package/dist/catalog-contracts.d.ts +2 -0
- package/dist/catalog-contracts.d.ts.map +1 -1
- package/dist/catalog-database-sync-store.d.ts.map +1 -1
- package/dist/catalog-models.d.ts +5 -0
- package/dist/catalog-models.d.ts.map +1 -1
- package/dist/catalog-preview.d.ts +63 -0
- package/dist/catalog-preview.d.ts.map +1 -0
- package/dist/catalog-read-service.d.ts +3 -0
- package/dist/catalog-read-service.d.ts.map +1 -1
- package/dist/catalog.d.ts +4 -4
- package/dist/catalog.d.ts.map +1 -1
- package/dist/chunks/{workbook-photo-service-1wcnf-Xf.js → workbook-photo-service-DCmqeWLd.js} +448 -202
- package/dist/chunks/workbook-photo-service-DCmqeWLd.js.map +1 -0
- package/dist/course-source.d.ts +2 -2
- package/dist/course-source.d.ts.map +1 -1
- package/dist/family-intake-contracts.d.ts +56 -0
- package/dist/family-intake-contracts.d.ts.map +1 -0
- package/dist/family-intake-ingestion.d.ts +29 -0
- package/dist/family-intake-ingestion.d.ts.map +1 -0
- package/dist/family-intake-models.d.ts +58 -0
- package/dist/family-intake-models.d.ts.map +1 -0
- package/dist/family-intake-service.d.ts +70 -0
- package/dist/family-intake-service.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +289 -13
- package/dist/index.js.map +1 -1
- package/dist/ingestion.d.ts +2 -0
- package/dist/ingestion.d.ts.map +1 -1
- package/dist/ingestion.js +426 -13
- package/dist/ingestion.js.map +1 -1
- package/dist/intake-original-evidence.d.ts +10 -0
- package/dist/intake-original-evidence.d.ts.map +1 -0
- package/dist/learner-service.d.ts +2 -0
- package/dist/learner-service.d.ts.map +1 -1
- package/dist/learners.d.ts +5 -5
- package/dist/learners.d.ts.map +1 -1
- package/dist/lesson-assets.d.ts +2 -2
- package/dist/lesson-assets.d.ts.map +1 -1
- package/dist/manifest.json +1447 -162
- package/dist/smrt-knowledge.json +563 -10
- package/dist/sources/public-illustrative-mathematics-adapter.d.ts +4 -2
- package/dist/sources/public-illustrative-mathematics-adapter.d.ts.map +1 -1
- package/dist/sources/public-illustrative-mathematics-parser.d.ts +5 -0
- package/dist/sources/public-illustrative-mathematics-parser.d.ts.map +1 -1
- package/dist/submission-service.d.ts +1 -1
- package/dist/submission-service.d.ts.map +1 -1
- package/dist/submissions.d.ts +7 -7
- package/dist/submissions.d.ts.map +1 -1
- package/dist/workbook-models.d.ts +8 -8
- package/dist/workbook-models.d.ts.map +1 -1
- package/dist/workbook-photo-ingestion.d.ts.map +1 -1
- package/dist/workbook-photo-models.d.ts +6 -5
- package/dist/workbook-photo-models.d.ts.map +1 -1
- package/dist/workbook-photo-service.d.ts +6 -0
- package/dist/workbook-photo-service.d.ts.map +1 -1
- package/package.json +10 -10
- package/dist/chunks/workbook-photo-service-1wcnf-Xf.js.map +0 -1
package/dist/ingestion.d.ts
CHANGED
|
@@ -136,5 +136,7 @@ export declare function preserveWorkbookIntakeAnalysis(input: {
|
|
|
136
136
|
submissionId: string;
|
|
137
137
|
workbookVocabulary?: WorkbookAssessment['vocabulary'];
|
|
138
138
|
}): Promise<WorkbookPreservationResult>;
|
|
139
|
+
export { receiveFamilyIntake, retainFamilyIntakeReceipt, } from './family-intake-ingestion.js';
|
|
140
|
+
export { FamilyIntakeService } from './family-intake-service.js';
|
|
139
141
|
export { preserveWorkbookPhotoIdentification, processWorkbookPhotoAnalysis, receiveWorkbookPhotoIntake, retainWorkbookPhotoReceipt, WORKBOOK_PHOTO_HANDLER, workbookPhotoIntakeHandlers, workbookPhotoIntakeTargetResolver, } from './workbook-photo-ingestion.js';
|
|
140
142
|
//# sourceMappingURL=ingestion.d.ts.map
|
package/dist/ingestion.d.ts.map
CHANGED
|
@@ -1 +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,EAGrB,KAAK,gBAAgB,EAGtB,MAAM,sCAAsC,CAAC;
|
|
1
|
+
{"version":3,"file":"ingestion.d.ts","sourceRoot":"","sources":["../src/ingestion.ts"],"names":[],"mappings":"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"}
|
package/dist/ingestion.js
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
|
-
import { F as WorkbookAssessmentValidationError, I as aggregateWorkbookQuestions, L as validateWorkbookAssessment, P as WORKBOOK_MATH_VOCABULARY, R as workbookAggregateFeedback, f as validateWorkbookPhotoIdentification, m as normalizeWorkbookAnswerRegions, n as preservePhotoProposal, p as normalizeWorkbookObservedLayout, r as WorkbookService } from "./chunks/workbook-photo-service-
|
|
1
|
+
import { F as WorkbookAssessmentValidationError, G as familyIntakeScopeId, H as FamilyIntakeCollection, I as aggregateWorkbookQuestions, L as validateWorkbookAssessment, P as WORKBOOK_MATH_VOCABULARY, R as workbookAggregateFeedback, V as FamilyIntakeAssetCollection, W as FamilyIntakeDecisionCollection, d as WorkbookPhotoIntakeCollection, f as validateWorkbookPhotoIdentification, m as normalizeWorkbookAnswerRegions, n as preservePhotoProposal, p as normalizeWorkbookObservedLayout, r as WorkbookService, s as WorkbookPhotoAssetCollection, t as WorkbookPhotoService, v as LearnerAuthorizationError } from "./chunks/workbook-photo-service-DCmqeWLd.js";
|
|
2
2
|
import { getCurrentTenant, withTenant } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
import { withPrivateAssetAccess } from "@happyvertical/smrt-assets";
|
|
3
4
|
import { registerPermissionDefinitions } from "@happyvertical/smrt-users";
|
|
4
5
|
import { GenerationSnapshotStaleError, ProposalsNotConfiguredError, extractAnalysis, normalizeObservedImageRegions } from "@happyvertical/smrt-ingestion/server";
|
|
5
6
|
import { readFile } from "node:fs/promises";
|
|
6
7
|
import { resolve } from "node:path";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
//#region src/intake-original-evidence.ts
|
|
10
|
+
var supportedIntakeOriginal = (type) => [
|
|
11
|
+
"image/png",
|
|
12
|
+
"image/jpeg",
|
|
13
|
+
"image/webp",
|
|
14
|
+
"application/pdf"
|
|
15
|
+
].includes(type);
|
|
16
|
+
function intakeOriginalEvidence(evidence) {
|
|
17
|
+
const byId = new Map(evidence.map((entry) => [entry.id, entry]));
|
|
18
|
+
return evidence.filter((entry) => {
|
|
19
|
+
let parentId = entry.parentEvidenceId;
|
|
20
|
+
const visited = /* @__PURE__ */ new Set([entry.id]);
|
|
21
|
+
while (parentId) {
|
|
22
|
+
if (visited.has(parentId)) throw new Error("Invalid family evidence lineage");
|
|
23
|
+
visited.add(parentId);
|
|
24
|
+
const parent = byId.get(parentId);
|
|
25
|
+
if (!parent) throw new Error("Incomplete family evidence lineage");
|
|
26
|
+
if (supportedIntakeOriginal(parent.mediaType)) return false;
|
|
27
|
+
parentId = parent.parentEvidenceId;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}).sort((a, b) => a.partId.localeCompare(b.partId));
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
8
33
|
//#region src/ingestion-isolation.ts
|
|
9
34
|
async function requireIngestionMemoryLimit(maximumBytes) {
|
|
10
35
|
if (process.platform !== "linux") throw new Error("Extraction requires a Linux cgroup-v2 memory-limited worker");
|
|
@@ -31,6 +56,388 @@ async function requireIngestionMemoryLimit(maximumBytes) {
|
|
|
31
56
|
return { memoryLimitBytes: limit };
|
|
32
57
|
}
|
|
33
58
|
//#endregion
|
|
59
|
+
//#region src/family-intake-ingestion.ts
|
|
60
|
+
function assertScope$1(input) {
|
|
61
|
+
if (input.scope.tenantId !== input.request.tenantId || input.scope.actorId !== input.request.actorUserId || input.scope.confidentialScopeId !== familyIntakeScopeId(input.request.tenantId)) throw new Error("Family intake scope mismatch");
|
|
62
|
+
}
|
|
63
|
+
async function receiveFamilyIntake(input) {
|
|
64
|
+
assertScope$1(input);
|
|
65
|
+
await input.family.authorizeFamilyIntake(input.request, "education.family-intake.receive");
|
|
66
|
+
const receipt = await input.ingestion.receive(input.receive);
|
|
67
|
+
if (!("itemId" in receipt) || !receipt.itemId) return {
|
|
68
|
+
receipt,
|
|
69
|
+
intake: null
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
receipt,
|
|
73
|
+
intake: await retainFamilyIntakeReceipt({
|
|
74
|
+
...input,
|
|
75
|
+
itemId: receipt.itemId
|
|
76
|
+
})
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function retainFamilyIntakeReceipt(input) {
|
|
80
|
+
assertScope$1(input);
|
|
81
|
+
await input.family.authorizeFamilyIntake(input.request, "education.family-intake.receive");
|
|
82
|
+
const identity = await input.ingestion.getReceiptSource(input.itemId);
|
|
83
|
+
const evidence = await input.ingestion.getEvidence(input.itemId);
|
|
84
|
+
if (evidence.length > 200) throw new Error("Family receipt evidence exceeds limit");
|
|
85
|
+
const source = {
|
|
86
|
+
...identity,
|
|
87
|
+
evidence: evidence.map((e) => ({
|
|
88
|
+
evidenceId: e.id,
|
|
89
|
+
partId: e.partId,
|
|
90
|
+
parentEvidenceId: e.parentEvidenceId,
|
|
91
|
+
mimeType: e.mediaType,
|
|
92
|
+
sha256: e.contentHash
|
|
93
|
+
}))
|
|
94
|
+
};
|
|
95
|
+
const supported = supportedIntakeOriginal;
|
|
96
|
+
const originalEvidence = intakeOriginalEvidence(evidence);
|
|
97
|
+
if (originalEvidence.some((e) => e.mediaType !== "application/json" && !supported(e.mediaType))) throw new Error("Unsupported family original: only JPEG, PNG, WebP and PDF are supported");
|
|
98
|
+
const originals = originalEvidence.filter((e) => supported(e.mediaType));
|
|
99
|
+
if (!originals.length || originals.length > 100 || originals.some((e) => e.byteLength < 1 || e.byteLength > 25 * 1024 * 1024) || originals.reduce((n, e) => n + e.byteLength, 0) > 100 * 1024 * 1024) throw new Error("Unsupported or oversized family intake");
|
|
100
|
+
const pages = [];
|
|
101
|
+
const extensions = {
|
|
102
|
+
"image/png": "png",
|
|
103
|
+
"image/jpeg": "jpg",
|
|
104
|
+
"image/webp": "webp",
|
|
105
|
+
"application/pdf": "pdf"
|
|
106
|
+
};
|
|
107
|
+
for (const original of originals) {
|
|
108
|
+
if (!Object.hasOwn(extensions, original.mediaType)) throw new Error("Unsupported family original");
|
|
109
|
+
const data = await input.ingestion.readEvidence(input.itemId, original.id);
|
|
110
|
+
if (data.length !== original.byteLength || createHash("sha256").update(data).digest("hex") !== original.contentHash) throw new Error("Family receipt integrity mismatch");
|
|
111
|
+
pages.push({
|
|
112
|
+
name: original.filename || `${original.id}.${extensions[original.mediaType]}`,
|
|
113
|
+
mimeType: original.mediaType,
|
|
114
|
+
data: Buffer.from(data)
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return input.family.retainFamilyIntake({
|
|
118
|
+
...input.request,
|
|
119
|
+
itemId: input.itemId,
|
|
120
|
+
source,
|
|
121
|
+
pages
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/family-intake-service.ts
|
|
126
|
+
var canonical$1 = (value) => JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
|
|
127
|
+
function digest(value) {
|
|
128
|
+
const crypto = typeof process !== "undefined" && process.getBuiltinModule?.("node:crypto");
|
|
129
|
+
if (!crypto) throw new Error("Family intake requires a Node server");
|
|
130
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
131
|
+
}
|
|
132
|
+
function key(value) {
|
|
133
|
+
if (typeof value !== "string" || !value.trim() || value.length > 200 || Array.from(value).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) throw new Error("Invalid family intake key");
|
|
134
|
+
}
|
|
135
|
+
var FamilyIntakeService = class FamilyIntakeService extends WorkbookPhotoService {
|
|
136
|
+
constructor(db, authorize, authorizeCatalog, familyOptions, authorizeFamily) {
|
|
137
|
+
super(db, authorize, authorizeCatalog, familyOptions);
|
|
138
|
+
this.familyOptions = familyOptions;
|
|
139
|
+
this.authorizeFamily = authorizeFamily;
|
|
140
|
+
}
|
|
141
|
+
familyOptions;
|
|
142
|
+
authorizeFamily;
|
|
143
|
+
createTransactionService(db) {
|
|
144
|
+
return new FamilyIntakeService(db, this.authorize, this.authorizeCatalog, this.familyOptions, this.authorizeFamily);
|
|
145
|
+
}
|
|
146
|
+
async authorizeFamilyIntake(request, action = "education.family-intake.read") {
|
|
147
|
+
this.assertServiceOpen();
|
|
148
|
+
const context = getCurrentTenant();
|
|
149
|
+
if (!context || context.superAdminBypass || context.tenantId !== request.tenantId || context.userId !== request.actorUserId || !request.tenantId || !request.actorUserId || !await this.db.get("memberships", {
|
|
150
|
+
tenant_id: request.tenantId,
|
|
151
|
+
user_id: request.actorUserId,
|
|
152
|
+
status: "active"
|
|
153
|
+
}) || !await this.db.get("users", {
|
|
154
|
+
id: request.actorUserId,
|
|
155
|
+
status: "active"
|
|
156
|
+
}) || await this.authorizeFamily({
|
|
157
|
+
...request,
|
|
158
|
+
executor: this.db,
|
|
159
|
+
action
|
|
160
|
+
}) !== true) throw new LearnerAuthorizationError();
|
|
161
|
+
}
|
|
162
|
+
async familyRow(request) {
|
|
163
|
+
await this.authorizeFamilyIntake(request);
|
|
164
|
+
const row = await (await FamilyIntakeCollection.create({ db: this.db })).get({
|
|
165
|
+
id: request.intakeId,
|
|
166
|
+
tenantId: request.tenantId
|
|
167
|
+
}, { cache: false });
|
|
168
|
+
if (!row) throw new LearnerAuthorizationError();
|
|
169
|
+
return row;
|
|
170
|
+
}
|
|
171
|
+
async familyDecisions(request) {
|
|
172
|
+
return (await (await FamilyIntakeDecisionCollection.create({ db: this.db })).list({
|
|
173
|
+
where: {
|
|
174
|
+
tenantId: request.tenantId,
|
|
175
|
+
intakeId: request.intakeId
|
|
176
|
+
},
|
|
177
|
+
limit: 1e4
|
|
178
|
+
})).sort((a, b) => a.sequence - b.sequence);
|
|
179
|
+
}
|
|
180
|
+
async lockFamily(request) {
|
|
181
|
+
await this.db.query("UPDATE family_intakes SET id=id WHERE id=? AND tenant_id=?", [request.intakeId, request.tenantId]);
|
|
182
|
+
}
|
|
183
|
+
async appendFamily(request, requestKey, body) {
|
|
184
|
+
return (await FamilyIntakeDecisionCollection.create({ db: this.db })).create({
|
|
185
|
+
_insertOnly: true,
|
|
186
|
+
tenantId: request.tenantId,
|
|
187
|
+
intakeId: request.intakeId,
|
|
188
|
+
actorUserId: request.actorUserId,
|
|
189
|
+
requestKey,
|
|
190
|
+
sequence: (await this.familyDecisions(request)).length + 1,
|
|
191
|
+
bodyJson: canonical$1(body)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async retainFamilyIntake(request) {
|
|
195
|
+
return this.composeMutation(async (service) => {
|
|
196
|
+
if (service.executorBorrowed) throw new Error("Family storage requires an owned transaction");
|
|
197
|
+
await service.authorizeFamilyIntake(request, "education.family-intake.receive");
|
|
198
|
+
key(request.itemId);
|
|
199
|
+
for (const value of [
|
|
200
|
+
request.source?.sourceId,
|
|
201
|
+
request.source?.sourceVersion,
|
|
202
|
+
request.source?.deliveryKey
|
|
203
|
+
]) key(value);
|
|
204
|
+
if (Buffer.byteLength(canonical$1(request.source)) > 128 * 1024) throw new Error("Family source provenance exceeds limit");
|
|
205
|
+
if (request.source.evidence !== void 0) {
|
|
206
|
+
if (!Array.isArray(request.source.evidence) || request.source.evidence.length > 200) throw new Error("Invalid family source evidence");
|
|
207
|
+
for (const evidence of request.source.evidence) {
|
|
208
|
+
for (const value of [
|
|
209
|
+
evidence.evidenceId,
|
|
210
|
+
evidence.partId,
|
|
211
|
+
evidence.mimeType
|
|
212
|
+
]) key(value);
|
|
213
|
+
if (evidence.parentEvidenceId !== null) key(evidence.parentEvidenceId);
|
|
214
|
+
if (typeof evidence.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(evidence.sha256)) throw new Error("Invalid family source evidence hash");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const source = {
|
|
218
|
+
sourceId: request.source.sourceId,
|
|
219
|
+
sourceVersion: request.source.sourceVersion,
|
|
220
|
+
deliveryKey: request.source.deliveryKey,
|
|
221
|
+
...request.source.evidence ? { evidence: request.source.evidence.map((e) => ({
|
|
222
|
+
evidenceId: e.evidenceId,
|
|
223
|
+
partId: e.partId,
|
|
224
|
+
parentEvidenceId: e.parentEvidenceId,
|
|
225
|
+
mimeType: e.mimeType,
|
|
226
|
+
sha256: e.sha256
|
|
227
|
+
})) } : {}
|
|
228
|
+
};
|
|
229
|
+
service.validatePages(request.pages);
|
|
230
|
+
if (request.pages.reduce((n, p) => n + p.data.length, 0) > 100 * 1024 * 1024) throw new Error("Family intake exceeds total byte limit");
|
|
231
|
+
await service.db.query("UPDATE tenants SET id=id WHERE id=?", [request.tenantId]);
|
|
232
|
+
const contentDigest = digest(canonical$1({
|
|
233
|
+
source,
|
|
234
|
+
pages: request.pages.map((p) => ({
|
|
235
|
+
name: p.name,
|
|
236
|
+
mimeType: p.mimeType,
|
|
237
|
+
sha256: digest(p.data)
|
|
238
|
+
}))
|
|
239
|
+
}));
|
|
240
|
+
const collection = await FamilyIntakeCollection.create({ db: service.db });
|
|
241
|
+
const prior = await collection.get({
|
|
242
|
+
tenantId: request.tenantId,
|
|
243
|
+
itemId: request.itemId
|
|
244
|
+
}, { cache: false });
|
|
245
|
+
if (prior) {
|
|
246
|
+
if (prior.contentDigest !== contentDigest) throw new Error("Family receipt retry content conflict");
|
|
247
|
+
return service.getFamilyIntake({
|
|
248
|
+
...request,
|
|
249
|
+
intakeId: String(prior.id)
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const actor = await service.db.get("users", { id: request.actorUserId });
|
|
253
|
+
if (!actor?.profile_id) throw new LearnerAuthorizationError();
|
|
254
|
+
const row = await collection.create({
|
|
255
|
+
_insertOnly: true,
|
|
256
|
+
tenantId: request.tenantId,
|
|
257
|
+
itemId: request.itemId,
|
|
258
|
+
actorUserId: request.actorUserId,
|
|
259
|
+
ownerProfileId: actor.profile_id,
|
|
260
|
+
contentDigest,
|
|
261
|
+
sourceJson: canonical$1(source)
|
|
262
|
+
});
|
|
263
|
+
const links = await FamilyIntakeAssetCollection.create({ db: service.db });
|
|
264
|
+
for (const [position, page] of request.pages.entries()) {
|
|
265
|
+
const asset = await service.storePage(request, actor.profile_id, page);
|
|
266
|
+
await links.create({
|
|
267
|
+
_insertOnly: true,
|
|
268
|
+
tenantId: request.tenantId,
|
|
269
|
+
intakeId: String(row.id),
|
|
270
|
+
assetId: String(asset.id),
|
|
271
|
+
name: page.name,
|
|
272
|
+
mimeType: page.mimeType,
|
|
273
|
+
sha256: digest(page.data),
|
|
274
|
+
position
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return service.getFamilyIntake({
|
|
278
|
+
...request,
|
|
279
|
+
intakeId: String(row.id)
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
async getFamilyIntake(request) {
|
|
284
|
+
const row = await this.familyRow(request);
|
|
285
|
+
const decisions = await this.familyDecisions(request);
|
|
286
|
+
const assignment = decisions.map((d) => JSON.parse(d.bodyJson)).findLast((d) => d.kind === "assign");
|
|
287
|
+
const adoption = decisions.map((d) => JSON.parse(d.bodyJson)).find((d) => d.kind === "adopt");
|
|
288
|
+
const originals = await (await FamilyIntakeAssetCollection.create({ db: this.db })).list({
|
|
289
|
+
where: {
|
|
290
|
+
tenantId: request.tenantId,
|
|
291
|
+
intakeId: request.intakeId
|
|
292
|
+
},
|
|
293
|
+
orderBy: "position",
|
|
294
|
+
limit: 100
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
id: String(row.id),
|
|
298
|
+
itemId: row.itemId,
|
|
299
|
+
source: JSON.parse(row.sourceJson),
|
|
300
|
+
revision: decisions.length,
|
|
301
|
+
learnerId: assignment?.learnerId ?? null,
|
|
302
|
+
photoIntakeId: adoption?.kind === "adopt" ? adoption.photoIntakeId : null,
|
|
303
|
+
originals: originals.map((o) => ({
|
|
304
|
+
assetId: o.assetId,
|
|
305
|
+
name: o.name,
|
|
306
|
+
mimeType: o.mimeType,
|
|
307
|
+
sha256: o.sha256
|
|
308
|
+
}))
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async listFamilyIntakes(request, options = {}) {
|
|
312
|
+
await this.authorizeFamilyIntake(request);
|
|
313
|
+
const limit = options.limit ?? 20, offset = options.offset ?? 0;
|
|
314
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid family inbox page");
|
|
315
|
+
const rows = await (await FamilyIntakeCollection.create({ db: this.db })).list({
|
|
316
|
+
where: { tenantId: request.tenantId },
|
|
317
|
+
orderBy: "createdAt DESC",
|
|
318
|
+
limit,
|
|
319
|
+
offset
|
|
320
|
+
});
|
|
321
|
+
return Promise.all(rows.map((row) => this.getFamilyIntake({
|
|
322
|
+
...request,
|
|
323
|
+
intakeId: String(row.id)
|
|
324
|
+
})));
|
|
325
|
+
}
|
|
326
|
+
async countFamilyIntakes(request) {
|
|
327
|
+
await this.authorizeFamilyIntake(request);
|
|
328
|
+
const rows = await this.db.query("SELECT COUNT(*) AS total FROM family_intakes f WHERE f.tenant_id=? AND NOT EXISTS (SELECT 1 FROM workbook_photo_intakes p WHERE p.tenant_id=f.tenant_id AND p.family_intake_id=f.id)", [request.tenantId]);
|
|
329
|
+
return Number(rows.rows[0]?.total ?? 0);
|
|
330
|
+
}
|
|
331
|
+
async getFamilyIntakeTimeline(request) {
|
|
332
|
+
await this.familyRow(request);
|
|
333
|
+
return (await this.familyDecisions(request)).map((d) => ({
|
|
334
|
+
id: String(d.id),
|
|
335
|
+
revision: d.sequence,
|
|
336
|
+
actorUserId: d.actorUserId,
|
|
337
|
+
requestKey: d.requestKey,
|
|
338
|
+
decision: JSON.parse(d.bodyJson)
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
async readFamilyIntakeOriginal(request) {
|
|
342
|
+
const row = await this.familyRow(request);
|
|
343
|
+
const original = (await this.getFamilyIntake(request)).originals.find((o) => o.assetId === request.assetId);
|
|
344
|
+
if (!original) throw new LearnerAuthorizationError();
|
|
345
|
+
return withPrivateAssetAccess({
|
|
346
|
+
tenantId: request.tenantId,
|
|
347
|
+
actorUserId: request.actorUserId,
|
|
348
|
+
ownerProfileId: row.ownerProfileId,
|
|
349
|
+
assetIds: [request.assetId]
|
|
350
|
+
}, async () => {
|
|
351
|
+
const runtime = await this.assets();
|
|
352
|
+
const asset = await runtime.collection.get({
|
|
353
|
+
id: request.assetId,
|
|
354
|
+
tenantId: request.tenantId,
|
|
355
|
+
ownerProfileId: row.ownerProfileId
|
|
356
|
+
}, { cache: false });
|
|
357
|
+
if (asset?.accessMode !== "private") throw new LearnerAuthorizationError();
|
|
358
|
+
const data = await runtime.store.read(asset);
|
|
359
|
+
if (digest(data) !== original.sha256) throw new Error("Retained family original integrity mismatch");
|
|
360
|
+
return {
|
|
361
|
+
data,
|
|
362
|
+
mimeType: original.mimeType
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
async assignFamilyIntake(request) {
|
|
367
|
+
return this.composeMutation(async (service) => {
|
|
368
|
+
await service.authorizeFamilyIntake(request, "education.family-intake.assign");
|
|
369
|
+
await service.lockFamily(request);
|
|
370
|
+
await service.requireActor(request, "education.learner.plan");
|
|
371
|
+
key(request.requestKey);
|
|
372
|
+
key(request.reason);
|
|
373
|
+
const view = await service.getFamilyIntake(request);
|
|
374
|
+
const body = {
|
|
375
|
+
kind: "assign",
|
|
376
|
+
learnerId: request.learnerId,
|
|
377
|
+
reason: request.reason
|
|
378
|
+
};
|
|
379
|
+
const prior = (await service.familyDecisions(request)).find((d) => d.requestKey === request.requestKey);
|
|
380
|
+
if (prior) {
|
|
381
|
+
if (prior.bodyJson !== canonical$1(body)) throw new Error("Family decision retry content conflict");
|
|
382
|
+
return view;
|
|
383
|
+
}
|
|
384
|
+
if (view.photoIntakeId) throw new Error("Adopted family intake cannot be retargeted");
|
|
385
|
+
if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision !== view.revision) throw new Error("Family intake revision conflict");
|
|
386
|
+
await service.appendFamily(request, request.requestKey, body);
|
|
387
|
+
return service.getFamilyIntake(request);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/** Freeze the explicit assignment and borrow exact originals; no capture or grading. */
|
|
391
|
+
async adoptFamilyIntake(request) {
|
|
392
|
+
return this.composeMutation(async (service) => {
|
|
393
|
+
await service.authorizeFamilyIntake(request, "education.family-intake.assign");
|
|
394
|
+
await service.lockFamily(request);
|
|
395
|
+
await service.requireActor(request, "education.learner.plan");
|
|
396
|
+
key(request.requestKey);
|
|
397
|
+
const view = await service.getFamilyIntake(request);
|
|
398
|
+
const prior = (await service.familyDecisions(request)).find((d) => d.requestKey === request.requestKey);
|
|
399
|
+
if (prior) {
|
|
400
|
+
const body = JSON.parse(prior.bodyJson);
|
|
401
|
+
if (body.kind !== "adopt" || body.learnerId !== request.learnerId) throw new Error("Family decision retry content conflict");
|
|
402
|
+
return view;
|
|
403
|
+
}
|
|
404
|
+
if (view.photoIntakeId || view.learnerId !== request.learnerId) throw new Error("Family intake assignment mismatch");
|
|
405
|
+
if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision !== view.revision) throw new Error("Family intake revision conflict");
|
|
406
|
+
const row = await service.familyRow(request);
|
|
407
|
+
for (const original of view.originals) await service.readFamilyIntakeOriginal({
|
|
408
|
+
...request,
|
|
409
|
+
assetId: original.assetId
|
|
410
|
+
});
|
|
411
|
+
const photoIntakeId = globalThis.crypto.randomUUID();
|
|
412
|
+
await service.appendFamily(request, request.requestKey, {
|
|
413
|
+
kind: "adopt",
|
|
414
|
+
learnerId: request.learnerId,
|
|
415
|
+
photoIntakeId
|
|
416
|
+
});
|
|
417
|
+
const photo = await (await WorkbookPhotoIntakeCollection.create({ db: service.db })).create({
|
|
418
|
+
id: photoIntakeId,
|
|
419
|
+
_insertOnly: true,
|
|
420
|
+
tenantId: request.tenantId,
|
|
421
|
+
learnerId: request.learnerId,
|
|
422
|
+
familyIntakeId: request.intakeId,
|
|
423
|
+
itemId: row.itemId,
|
|
424
|
+
actorUserId: row.actorUserId,
|
|
425
|
+
contentDigest: row.contentDigest
|
|
426
|
+
});
|
|
427
|
+
const links = await WorkbookPhotoAssetCollection.create({ db: service.db });
|
|
428
|
+
for (const [position, original] of view.originals.entries()) await links.create({
|
|
429
|
+
_insertOnly: true,
|
|
430
|
+
tenantId: request.tenantId,
|
|
431
|
+
learnerId: request.learnerId,
|
|
432
|
+
intakeId: String(photo.id),
|
|
433
|
+
...original,
|
|
434
|
+
position
|
|
435
|
+
});
|
|
436
|
+
return service.getFamilyIntake(request);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
//#endregion
|
|
34
441
|
//#region src/workbook-photo-ingestion.ts
|
|
35
442
|
var MODEL = "@happyvertical/smrt-education:WorkbookPhotoIntake";
|
|
36
443
|
var WORKBOOK_PHOTO_HANDLER = "@happyvertical/smrt-education:identify-workbook-photo";
|
|
@@ -128,9 +535,12 @@ var schema = {
|
|
|
128
535
|
};
|
|
129
536
|
function workbookPhotoIntakeHandlers(input) {
|
|
130
537
|
const bound = async (context, work) => {
|
|
131
|
-
|
|
538
|
+
const checked = async (service) => {
|
|
539
|
+
await service.assertWorkbookPhotoScope(input.request, context.scope, context.itemId);
|
|
540
|
+
return work(service);
|
|
541
|
+
};
|
|
132
542
|
if (!context.policy.requireReview) throw new Error("Photo intake requires review policy");
|
|
133
|
-
if (!context.principal) return input.workbooks.withExecutor(context.db,
|
|
543
|
+
if (!context.principal) return input.workbooks.withExecutor(context.db, checked);
|
|
134
544
|
const tenant = getCurrentTenant();
|
|
135
545
|
if (!tenant || tenant.tenantId !== input.request.tenantId || tenant.userId !== context.principal.context.userId || context.principal.context.tenantId !== input.request.tenantId) throw new Error("Photo principal tenant mismatch");
|
|
136
546
|
return withTenant({
|
|
@@ -138,7 +548,7 @@ function workbookPhotoIntakeHandlers(input) {
|
|
|
138
548
|
tenantId: input.request.tenantId,
|
|
139
549
|
userId: input.request.actorUserId,
|
|
140
550
|
superAdminBypass: false
|
|
141
|
-
}, () => input.workbooks.withExecutor(context.db,
|
|
551
|
+
}, () => input.workbooks.withExecutor(context.db, checked));
|
|
142
552
|
};
|
|
143
553
|
const authorize = async (context) => bound(context, async (service) => {
|
|
144
554
|
await service.authorizePhotoIntake(input.request);
|
|
@@ -220,15 +630,17 @@ function workbookRevision(book) {
|
|
|
220
630
|
return `workbook:${createHash("sha256").update(canonical(book)).digest("hex")}`;
|
|
221
631
|
}
|
|
222
632
|
function workbookPhotoIntakeTargetResolver(input) {
|
|
223
|
-
return async ({ db, scope, model, id, revision }) => {
|
|
224
|
-
assertScope(scope, input.request);
|
|
633
|
+
return async ({ db, scope, itemId, model, id, revision }) => {
|
|
225
634
|
if (model !== "@happyvertical/smrt-education:Workbook") throw new Error("Photo target unavailable");
|
|
226
635
|
const book = (await withTenant({
|
|
227
636
|
...getCurrentTenant(),
|
|
228
637
|
tenantId: input.request.tenantId,
|
|
229
638
|
userId: input.request.actorUserId,
|
|
230
639
|
superAdminBypass: false
|
|
231
|
-
}, () => input.workbooks.withExecutor(db, (service) =>
|
|
640
|
+
}, () => input.workbooks.withExecutor(db, async (service) => {
|
|
641
|
+
await service.assertWorkbookPhotoScope(input.request, scope, itemId);
|
|
642
|
+
return service.listWorkbookPhotoWorkbooks(input.request);
|
|
643
|
+
}))).find((book2) => book2.id === id);
|
|
232
644
|
if (!book) throw new Error("Photo target unavailable");
|
|
233
645
|
const current = workbookRevision(book);
|
|
234
646
|
if (revision !== void 0 && revision !== current) throw new Error("Photo target changed");
|
|
@@ -279,8 +691,8 @@ async function retainWorkbookPhotoReceipt(input) {
|
|
|
279
691
|
});
|
|
280
692
|
}
|
|
281
693
|
async function preserveWorkbookPhotoIdentification(input) {
|
|
282
|
-
assertScope(input.scope, input.request);
|
|
283
694
|
const view = await input.workbooks.getWorkbookPhotoIntake(input.request);
|
|
695
|
+
await input.workbooks.assertWorkbookPhotoScope(input.request, input.scope, view.itemId);
|
|
284
696
|
let snapshot;
|
|
285
697
|
try {
|
|
286
698
|
snapshot = await input.ingestion.getCompletedAnalysis(view.itemId);
|
|
@@ -306,7 +718,7 @@ async function preserveWorkbookPhotoIdentification(input) {
|
|
|
306
718
|
requestKey: `analysis:${snapshot.attemptId}`,
|
|
307
719
|
proposal,
|
|
308
720
|
allowAutomaticResolution: generation.outcome === "proposals" && generation.suggestions.length === 1 && suggestions[0].alternatives.length === 0 && generation.splits.length === 0,
|
|
309
|
-
sourceHashes: snapshot.evidence.filter((e) => e.
|
|
721
|
+
sourceHashes: intakeOriginalEvidence(snapshot.evidence).filter((e) => e.mediaType !== "application/json").map((e) => e.contentHash),
|
|
310
722
|
provenance: {
|
|
311
723
|
attemptId: snapshot.attemptId,
|
|
312
724
|
revision: snapshot.revision,
|
|
@@ -320,8 +732,9 @@ async function preserveWorkbookPhotoIdentification(input) {
|
|
|
320
732
|
});
|
|
321
733
|
}
|
|
322
734
|
async function processWorkbookPhotoAnalysis(input) {
|
|
323
|
-
|
|
324
|
-
|
|
735
|
+
const view = await input.workbooks.getWorkbookPhotoIntake(input.request);
|
|
736
|
+
await input.workbooks.assertWorkbookPhotoScope(input.request, input.scope, view.itemId);
|
|
737
|
+
if (view.itemId !== input.itemId) throw new Error("Photo worker receipt mismatch");
|
|
325
738
|
const processing = await processEducationAnalysis(input);
|
|
326
739
|
const intake = await preserveWorkbookPhotoIdentification(input);
|
|
327
740
|
const capture = intake.status === "resolved" && ["lesson", "work"].includes(intake.identification?.kind ?? "") && intake.identification?.workState === "completed" ? await input.workbooks.captureWorkbookPhotoIntake({
|
|
@@ -1010,7 +1423,7 @@ async function preserveWorkbookIntakeAnalysis(input) {
|
|
|
1010
1423
|
activityId,
|
|
1011
1424
|
submissionId
|
|
1012
1425
|
});
|
|
1013
|
-
const originals = snapshot.evidence.filter((e) => e.
|
|
1426
|
+
const originals = intakeOriginalEvidence(snapshot.evidence).filter((e) => e.mediaType !== "application/json");
|
|
1014
1427
|
if (originals.length !== sources.length || originals.some((e, i) => e.contentHash !== sources[i].sha256)) throw new Error("Workbook retained source mismatch");
|
|
1015
1428
|
let assessment;
|
|
1016
1429
|
try {
|
|
@@ -1168,6 +1581,6 @@ async function preserveWorkbookIntakeAnalysis(input) {
|
|
|
1168
1581
|
}
|
|
1169
1582
|
}
|
|
1170
1583
|
//#endregion
|
|
1171
|
-
export { WORKBOOK_PHOTO_HANDLER, captureEducationIntake, educationIntakeHandlers, educationIntakeTargetResolver, preserveWorkbookIntakeAnalysis, preserveWorkbookPhotoIdentification, processEducationAnalysis, processWorkbookPhotoAnalysis, queueEducationExtraction, receiveWorkbookPhotoIntake, requireIngestionMemoryLimit, retainWorkbookPhotoReceipt, workbookPhotoIntakeHandlers, workbookPhotoIntakeTargetResolver };
|
|
1584
|
+
export { FamilyIntakeService, WORKBOOK_PHOTO_HANDLER, captureEducationIntake, educationIntakeHandlers, educationIntakeTargetResolver, preserveWorkbookIntakeAnalysis, preserveWorkbookPhotoIdentification, processEducationAnalysis, processWorkbookPhotoAnalysis, queueEducationExtraction, receiveFamilyIntake, receiveWorkbookPhotoIntake, requireIngestionMemoryLimit, retainFamilyIntakeReceipt, retainWorkbookPhotoReceipt, workbookPhotoIntakeHandlers, workbookPhotoIntakeTargetResolver };
|
|
1172
1585
|
|
|
1173
1586
|
//# sourceMappingURL=ingestion.js.map
|