@ivanbard/dd2l 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +47 -8
- package/bin/dd2l.mjs +7 -7
- package/docs/README.md +108 -11
- package/package.json +3 -2
- package/src/app-runner.mjs +58 -35
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +41 -19
- package/src/client.mjs +150 -137
- package/src/connector.mjs +587 -345
- package/src/discovery.mjs +45 -45
- package/src/documents.mjs +154 -154
- package/src/links.mjs +99 -99
- package/src/llm.mjs +43 -6
- package/src/media.mjs +54 -54
- package/src/ocr.mjs +24 -24
- package/src/office.mjs +61 -61
- package/src/pdf-worker.mjs +26 -26
- package/src/research.mjs +70 -0
- package/src/session.mjs +5 -5
- package/src/store.mjs +108 -106
- package/src/syllabus.mjs +191 -191
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +36 -14
- package/src/ui-demo.mjs +17 -1
- package/src/ui-server.mjs +51 -15
- package/src/windows-ocr.ps1 +32 -32
- package/ui/answer-renderer.js +88 -88
- package/ui/answer-view.js +47 -47
- package/ui/app.js +96 -13
- package/ui/assets/widget-announcements.svg +4 -4
- package/ui/assets/widget-shortcuts.svg +5 -5
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +15 -4
- package/ui/loading.js +33 -33
- package/ui/style.css +15 -0
- package/ui/widget-layout.js +39 -39
- package/ui/widgets.js +252 -252
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { matches } from '../ui/answer-view.js';
|
|
3
|
+
import { researchActions } from './research.mjs';
|
|
4
|
+
|
|
5
|
+
const text = maxLength => ({ type: 'string', maxLength });
|
|
6
|
+
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
7
|
+
const list = (items, maxItems, minItems = 0) => ({ type: 'array', items, minItems, maxItems });
|
|
8
|
+
export const workspacePlanSchema = object({
|
|
9
|
+
title: text(160), goal: text(1000), question: text(500),
|
|
10
|
+
sections: list(object({ title: text(160), purpose: text(500), sourceIds: list(text(80), 24, 1) }), 8),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const fail = message => Object.assign(new Error(message), { public: true });
|
|
14
|
+
const excerpt = (value, length) => value.length > length ? `${value.slice(0, length)}\n[Excerpt truncated.]` : value;
|
|
15
|
+
|
|
16
|
+
export function taskCatalogue(inventory, course = '') {
|
|
17
|
+
const allowed = new Set(inventory.courses.filter(item => !course || item.id === course).map(item => item.id));
|
|
18
|
+
const parents = new Set((inventory.assignments || []).map(assignment => `${assignment.course}:${assignment.id}`));
|
|
19
|
+
const sources = inventory.sources.filter(source => {
|
|
20
|
+
const assignment = source.id.match(/\/dropbox\/folders\/(\d+)(?:\/|#|$)/)?.[1] ||
|
|
21
|
+
(source.category === 'rubrics' ? source.id.match(/[?&]objectId=(\d+)(?:&|#|$)/)?.[1] : null);
|
|
22
|
+
return allowed.has(source.course) && (!assignment || parents.has(`${source.course}:${assignment}`));
|
|
23
|
+
}).map(source => ({ ...source,
|
|
24
|
+
ref: JSON.stringify([source.course, source.category, source.id, source.title]),
|
|
25
|
+
}));
|
|
26
|
+
for (const assignment of inventory.assignments || []) {
|
|
27
|
+
if (!allowed.has(assignment.course)) continue;
|
|
28
|
+
const parent = `${assignment.course}:${assignment.id}`;
|
|
29
|
+
sources.push({ ...assignment, category: 'assignment', ref: `${parent}:overview`,
|
|
30
|
+
text: `Due: ${assignment.dueDate || 'Not retrieved'}\nOpens: ${assignment.startDate || 'Not retrieved'}\nCloses: ${assignment.endDate || 'Not retrieved'}\n${assignment.submissionStatus}`,
|
|
31
|
+
records: undefined, parent });
|
|
32
|
+
for (const record of assignment.records) sources.push({ ...record, title: `${assignment.title} · ${record.title}`,
|
|
33
|
+
text: record.text || `${record.title}: ${record.status}. No readable text retrieved.`, ref: record.id, parent });
|
|
34
|
+
}
|
|
35
|
+
return sources.map(({ ref, records, ...source }) => ({ ...source,
|
|
36
|
+
id: createHash('sha256').update(ref).digest('base64url').slice(0, 24),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Bounded catalogue selection is distinct from evidence selection: the model sees varied records,
|
|
41
|
+
// including records with no literal query overlap, and can follow assignment relationships.
|
|
42
|
+
export function planningCatalogue(sources, query, context = null, preferred = []) {
|
|
43
|
+
const terms = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) || [])];
|
|
44
|
+
const previous = new Set(context?.sourceIds || []);
|
|
45
|
+
const ranked = sources.map((source, index) => ({ source, index,
|
|
46
|
+
score: (previous.has(source.id) ? 10 : 0) + terms.reduce((score, term) => score +
|
|
47
|
+
(source.title.toLowerCase().includes(term) ? 3 : source.text.toLowerCase().includes(term) ? 1 : 0), 0),
|
|
48
|
+
})).sort((a, b) => {
|
|
49
|
+
const left = preferred.indexOf(a.source.id), right = preferred.indexOf(b.source.id);
|
|
50
|
+
return (left < 0 ? Infinity : left) - (right < 0 ? Infinity : right) || b.score - a.score || a.index - b.index;
|
|
51
|
+
});
|
|
52
|
+
const selected = new Map(ranked.slice(0, 200).map(({ source }) => [source.id, source]));
|
|
53
|
+
// Reserve space for every course/category rather than letting a large course crowd out others.
|
|
54
|
+
const buckets = new Map();
|
|
55
|
+
for (const source of sources) {
|
|
56
|
+
const key = `${source.course}:${source.category}`;
|
|
57
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
58
|
+
buckets.get(key).push(source);
|
|
59
|
+
}
|
|
60
|
+
for (let round = 0; selected.size < 400; round++) {
|
|
61
|
+
let added = false;
|
|
62
|
+
for (const bucket of buckets.values()) {
|
|
63
|
+
if (selected.size >= 400) break;
|
|
64
|
+
const source = bucket[round];
|
|
65
|
+
if (source && !selected.has(source.id)) { selected.set(source.id, source); added = true; }
|
|
66
|
+
}
|
|
67
|
+
if (!added && [...buckets.values()].every(bucket => round >= bucket.length)) break;
|
|
68
|
+
}
|
|
69
|
+
return [...selected.values()].map(source => ({ id: source.id, course: source.courseName, category: source.category,
|
|
70
|
+
title: excerpt(source.title, 200), preview: excerpt(source.text, 400), parent: source.parent || null,
|
|
71
|
+
dueDate: source.dueDate || null, stale: !!source.stale, status: source.status || 'available',
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildTaskWorkspace(plan, sources, catalogueCount, total) {
|
|
76
|
+
const byId = new Map(sources.map(source => [source.id, source]));
|
|
77
|
+
if (!matches(plan, workspacePlanSchema) || !plan.title.trim() || !plan.goal.trim() || plan.sections.some(section => !section.sourceIds.length ||
|
|
78
|
+
section.sourceIds.some(id => !byId.has(id)))) throw fail('The workspace plan could not be verified. Please retry.');
|
|
79
|
+
const ids = [...new Set(plan.sections.flatMap(section => section.sourceIds))];
|
|
80
|
+
if (ids.length > 24 || (!ids.length && !plan.question.trim())) throw fail('The workspace plan needs sources or a clarifying question. Please retry.');
|
|
81
|
+
const evidence = ids.map(id => {
|
|
82
|
+
const { text, ...source } = byId.get(id);
|
|
83
|
+
return { ...source, excerpt: excerpt(text, 8000) };
|
|
84
|
+
});
|
|
85
|
+
return { workspace: { kind: 'task', title: plan.title, goal: plan.goal, question: plan.question,
|
|
86
|
+
sections: plan.sections.map(section => ({ ...section, sources: section.sourceIds.map(id => {
|
|
87
|
+
const source = byId.get(id);
|
|
88
|
+
return { id, title: source.title, category: source.category, courseName: source.courseName,
|
|
89
|
+
text: excerpt(source.text, 30_000), url: source.url, dueDate: source.dueDate || null,
|
|
90
|
+
stale: !!source.stale, status: source.status || 'available', lastSuccess: source.lastSuccess || null };
|
|
91
|
+
}) })),
|
|
92
|
+
context: { goal: plan.goal, sourceIds: ids }, catalogueCount, total }, sources: evidence };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validTaskContext(context) {
|
|
96
|
+
if (context === null) return true;
|
|
97
|
+
if (!context || typeof context !== 'object' || Array.isArray(context)) return false;
|
|
98
|
+
const { summary, ...references } = context;
|
|
99
|
+
return matches(references, object({ goal: text(1000), sourceIds: list(text(80), 24) })) &&
|
|
100
|
+
(summary === undefined || (typeof summary === 'string' && summary.length <= 12_000));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function composeTaskWorkspace(llm, inventory, query, model, course, context, signal, refresh = () => inventory, research = null) {
|
|
104
|
+
let accountScope = inventory.scope;
|
|
105
|
+
const checkedInventory = value => {
|
|
106
|
+
if (accountScope && value.scope !== accountScope) throw fail('School account changed or its cache was cleared. Start a new request.');
|
|
107
|
+
accountScope ||= value.scope;
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
for (let round = 0; round <= 4; round++) {
|
|
111
|
+
signal?.throwIfAborted();
|
|
112
|
+
const current = checkedInventory(round ? refresh() : inventory);
|
|
113
|
+
const all = taskCatalogue(current, course);
|
|
114
|
+
const preferred = research ? all.filter(source => research.keys.has(source.resourceKey))
|
|
115
|
+
.sort((a, b) => Number(!a.category.includes('file')) - Number(!b.category.includes('file')) || (Date.parse(b.lastSuccess) || 0) - (Date.parse(a.lastSuccess) || 0)).map(source => source.id) : [];
|
|
116
|
+
const catalogue = planningCatalogue(all, query, context, preferred);
|
|
117
|
+
const plan = await llm.planWorkspace(query, model, { catalogue, total: all.length,
|
|
118
|
+
courses: current.courses.filter(item => !course || item.id === course), now: new Date().toISOString(),
|
|
119
|
+
...(research ? { browsing: { available: round < 4 && research.available, remaining: round < 4 ? research.remaining : 0, history: research.history } } : {}),
|
|
120
|
+
}, context, signal);
|
|
121
|
+
if (plan.requests?.length) {
|
|
122
|
+
if (!matches(plan.requests, researchActions)) throw fail('The model returned invalid Brightspace read requests.');
|
|
123
|
+
if (!research || round === 4 || !research.available) throw fail('The model exceeded the live reading budget. Please narrow your request.');
|
|
124
|
+
if (plan.sections?.length) throw fail('The model must finish reading before selecting workspace sources.');
|
|
125
|
+
for (const action of plan.requests) {
|
|
126
|
+
if (!research.available) break;
|
|
127
|
+
await research.execute(action);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const { requests, ...finalPlan } = plan;
|
|
132
|
+
const visible = new Set(catalogue.map(source => source.id));
|
|
133
|
+
const task = buildTaskWorkspace(finalPlan, taskCatalogue(checkedInventory(refresh()), course).filter(source => visible.has(source.id)), catalogue.length, all.length);
|
|
134
|
+
if (research?.history.length) task.workspace.research = research.history.map(({ action, result }) => ({ action: action.action,
|
|
135
|
+
section: action.section || 'resource', outcome: result.outcome, message: result.message || '', outcomes: result.outcomes || [] }));
|
|
136
|
+
return task;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/ui-data.mjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { htmlContent } from './documents.mjs';
|
|
3
|
-
import { SCHOOL } from './client.mjs';
|
|
3
|
+
import { SCHOOL, schoolOrigin } from './client.mjs';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { projectAssignments } from './assignment-workspace.mjs';
|
|
5
6
|
|
|
6
7
|
const categories = new Set(['content', 'overview', 'syllabus_source', 'assignments', 'quizzes', 'announcements',
|
|
7
|
-
'calendar', 'calendar_occurrences', 'content_file', 'assignment_file', 'linked_content', 'video_transcript'
|
|
8
|
+
'calendar', 'calendar_occurrences', 'content_file', 'assignment_file', 'linked_content', 'video_transcript',
|
|
9
|
+
'quiz_details', 'own_quiz_attempts', 'grades', 'own_feedback', 'feedback_file', 'rubrics',
|
|
10
|
+
'discussion_forums', 'discussion_topics', 'discussion_posts', 'discussion_file']);
|
|
8
11
|
const textFields = new Set(['Title', 'Name', 'Subject', 'Text', 'Html', 'text', 'Description', 'Instructions',
|
|
9
|
-
'Body', 'Content', 'StartDate', 'EndDate', 'DueDate', 'StartDateTime', 'EndDateTime'
|
|
12
|
+
'Body', 'Content', 'StartDate', 'EndDate', 'DueDate', 'StartDateTime', 'EndDateTime',
|
|
13
|
+
'DisplayedGrade', 'PointsNumerator', 'PointsDenominator', 'WeightedNumerator', 'WeightedDenominator',
|
|
14
|
+
'Feedback', 'FeedbackText', 'Score', 'Points', 'MaxPoints', 'CompletionDate', 'TimeLimit']);
|
|
10
15
|
|
|
11
16
|
// Open for each request so logout, revoked access and new syncs are reflected immediately.
|
|
12
17
|
export function readInventory(path) {
|
|
@@ -20,9 +25,10 @@ export function readInventory(path) {
|
|
|
20
25
|
};
|
|
21
26
|
const state = meta('state');
|
|
22
27
|
const rows = db.prepare(`SELECT key,course,category,data,last_success,checked_at,outcome
|
|
23
|
-
FROM resources
|
|
28
|
+
FROM resources`).all()
|
|
24
29
|
.map(row => ({ ...row, data: JSON.parse(row.data) }));
|
|
25
|
-
|
|
30
|
+
const account = meta('account');
|
|
31
|
+
return { ...projectInventory(rows, state, meta('school_origin') ?? SCHOOL), scope: account ? createHash('sha256').update(account).digest('hex') : null };
|
|
26
32
|
} catch (error) {
|
|
27
33
|
if (error.code === 'ERR_SQLITE_ERROR' && /unable to open database/.test(error.message)) {
|
|
28
34
|
return { courses: [], sources: [], state: null };
|
|
@@ -31,44 +37,60 @@ export function readInventory(path) {
|
|
|
31
37
|
} finally { db?.close(); }
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
export function projectInventory(rows, state) {
|
|
40
|
+
export function projectInventory(rows, state, origin = SCHOOL) {
|
|
41
|
+
origin = schoolOrigin(origin);
|
|
35
42
|
const enrollment = rows.find(row => row.category === 'enrollments' && !['denied', 'not_configured'].includes(row.outcome));
|
|
36
43
|
const courses = (enrollment?.data ?? []).filter(item => item.Access?.CanAccess === true && item.OrgUnit?.Id)
|
|
37
44
|
.map(item => ({ id: String(item.OrgUnit.Id), name: item.OrgUnit.Name || `Course ${item.OrgUnit.Id}`,
|
|
38
45
|
active: item.Access.IsActive === true }));
|
|
39
46
|
const allowed = new Map(courses.map(course => [course.id, course]));
|
|
47
|
+
const assignments = projectAssignments(rows, courses, origin);
|
|
48
|
+
const assignmentParents = new Set(assignments.map(item => `${item.course}:${item.id}`));
|
|
49
|
+
const restrictedQuizzes = rows.filter(row => row.category === 'quiz_details' &&
|
|
50
|
+
(['denied', 'not_configured'].includes(row.outcome) || row.data?.IsHidden || row.data?.IsLocked || row.data?.IsPublished === false));
|
|
40
51
|
const sources = [];
|
|
41
52
|
for (const row of rows) {
|
|
42
53
|
if (!categories.has(row.category) || !allowed.has(String(row.course)) || ['denied', 'not_configured'].includes(row.outcome)) continue;
|
|
43
|
-
const
|
|
54
|
+
const assignment = row.key?.match(/\/dropbox\/folders\/(\d+)(?:\/|$)/)?.[1] ||
|
|
55
|
+
(row.category === 'rubrics' ? row.key?.match(/[?&]objectId=(\d+)(?:&|$)/)?.[1] : null);
|
|
56
|
+
if (assignment && !assignmentParents.has(`${row.course}:${assignment}`)) continue;
|
|
57
|
+
if (restrictedQuizzes.some(quiz => String(quiz.course) === String(row.course) && row.key?.startsWith(`${quiz.key}/`))) continue;
|
|
58
|
+
const visit = (value, parentTitle = '', depth = 0, path = '$') => {
|
|
44
59
|
if (!value || typeof value !== 'object' || depth > 15) return;
|
|
45
|
-
if (Array.isArray(value)) {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const [index, item] of value.entries()) {
|
|
62
|
+
const id = item?.TopicId ?? item?.Id ?? item?.NewsId ?? item?.GradeObjectIdentifier;
|
|
63
|
+
visit(item, parentTitle, depth + 1, `${path}/${/^\d+$/.test(String(id)) ? `id${id}` : index}`);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
46
67
|
if (value.IsHidden || value.IsLocked || value.IsDeleted || value.IsPublished === false ||
|
|
47
68
|
Date.parse(value.StartDateTime) > Date.now() || Date.parse(value.EndDateTime) < Date.now() ||
|
|
48
69
|
(row.category === 'announcements' && (Date.parse(value.StartDate) > Date.now() || Date.parse(value.EndDate) < Date.now()))) return;
|
|
49
70
|
const title = String(value.Title || value.Name || value.Subject || value.title || parentTitle || row.category.replaceAll('_', ' '));
|
|
50
71
|
const parts = [];
|
|
51
72
|
const collect = (item, field, level = 0) => {
|
|
52
|
-
if (typeof item === '
|
|
73
|
+
if (typeof item === 'number') parts.push(`${field}: ${item}`);
|
|
74
|
+
else if (typeof item === 'string') parts.push(['Html', 'Description', 'Instructions', 'Body', 'Content'].includes(field) ? htmlContent(item).text : item);
|
|
53
75
|
else if (item && typeof item === 'object' && !Array.isArray(item) && level < 3) {
|
|
54
76
|
for (const [key, child] of Object.entries(item)) if (textFields.has(key)) collect(child, key, level + 1);
|
|
55
77
|
}
|
|
56
78
|
};
|
|
57
79
|
for (const [key, child] of Object.entries(value)) if (textFields.has(key)) collect(child, key);
|
|
58
80
|
const text = [...new Set(parts.filter(Boolean))].join('\n').slice(0, 1_000_000);
|
|
59
|
-
if (text) sources.push({ id: `${row.key}#${
|
|
81
|
+
if (text) sources.push({ id: `${row.key}#${path}`, resourceKey: row.key, course: String(row.course),
|
|
60
82
|
courseName: allowed.get(String(row.course)).name, title, text, category: row.category,
|
|
61
83
|
lastSuccess: row.last_success, checkedAt: row.checked_at, stale: row.outcome !== 'available',
|
|
62
|
-
dueDate: ['assignments', 'quizzes', 'calendar', 'calendar_occurrences'].includes(row.category) && Number.isFinite(Date.parse(value.DueDate)) ? value.DueDate : null,
|
|
84
|
+
dueDate: ['assignments', 'quizzes', 'quiz_details', 'calendar', 'calendar_occurrences'].includes(row.category) && Number.isFinite(Date.parse(value.DueDate)) ? value.DueDate : null,
|
|
63
85
|
publishedAt: row.category === 'announcements' ? value.PublishedDate || value.StartDate || null : null,
|
|
64
|
-
url: `${
|
|
86
|
+
url: `${origin}/d2l/home/${row.course}` });
|
|
65
87
|
for (const [key, child] of Object.entries(value)) {
|
|
66
|
-
if (!textFields.has(key) && child && typeof child === 'object') visit(child, title, depth + 1);
|
|
88
|
+
if (!textFields.has(key) && child && typeof child === 'object') visit(child, title, depth + 1, `${path}/${key}`);
|
|
67
89
|
}
|
|
68
90
|
};
|
|
69
91
|
visit(row.data);
|
|
70
92
|
}
|
|
71
|
-
return { courses, sources, state };
|
|
93
|
+
return { courses, sources, assignments, state };
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
export function homeInventory(inventory, now = Date.now()) {
|
package/src/ui-demo.mjs
CHANGED
|
@@ -7,14 +7,30 @@ export function demoInventory() {
|
|
|
7
7
|
return { ...projectInventory([
|
|
8
8
|
row('enrollments', null, [
|
|
9
9
|
{ OrgUnit: { Id: 101, Name: 'CISC 121 · Introduction to Computing' }, Access: { CanAccess: true, IsActive: true } },
|
|
10
|
+
{ OrgUnit: { Id: 103, Name: 'ELEC 425 · Signals and Systems' }, Access: { CanAccess: true, IsActive: true } },
|
|
10
11
|
{ OrgUnit: { Id: 102, Name: 'PSYC 100 · Principles of Psychology' }, Access: { CanAccess: true, IsActive: true } },
|
|
11
12
|
]),
|
|
12
|
-
row('assignments', '101', [{ Name: 'Assignment 3 · Recursion', DueDate: '2026-11-09T23:59:00-05:00',
|
|
13
|
+
row('assignments', '101', [{ Id: 201, Name: 'Assignment 3 · Recursion', DueDate: '2026-11-09T23:59:00-05:00',
|
|
13
14
|
Instructions: { Text: 'Implement and test recursive functions. Submit your Python file and a short explanation through onQ. Review the recursion lecture before starting.' } }]),
|
|
14
15
|
row('syllabus_source', '101', { title: 'Course syllabus', text: 'Assessment: assignments 30%, labs 20%, final exam 50%. Office hours are Tuesday, 2–4 pm. Consult onQ for the current schedule.' }),
|
|
15
16
|
row('content', '102', { Modules: [{ Title: 'Week 8 · Memory', Topics: [
|
|
16
17
|
{ Title: 'Memory and retrieval', Description: { Text: 'Encoding, working memory, long-term memory, and retrieval practice. Read chapter 8 before the seminar.' } },
|
|
17
18
|
] }] }),
|
|
18
19
|
row('announcements', '102', [{ Title: 'Midterm review session', Body: { Text: 'Bring your questions about memory and learning to the review session on Thursday.' } }]),
|
|
20
|
+
row('assignments', '102', [{ Id: 203, Name: 'Assignment 2 · Memory journal', DueDate: '2026-11-13T23:59:00-05:00',
|
|
21
|
+
Instructions: { Text: 'Write a reflection on retrieval practice and your study habits.' } }]),
|
|
22
|
+
row('assignments', '103', [{ Id: 202, Name: 'Assignment 2 · Filter design', DueDate: '2026-11-12T23:59:00-05:00',
|
|
23
|
+
Instructions: { Text: 'Design a low-pass filter. Submit a PDF report with your calculations, frequency response plots, and MATLAB code. Review Frequency response before starting.' },
|
|
24
|
+
Attachments: [{ FileId: 7, FileName: 'Filter specifications.pdf' }] }]),
|
|
25
|
+
{ ...row('rubrics', '103', [{ Name: 'Filter design rubric', Criteria: [
|
|
26
|
+
{ Name: 'Design and calculations', Points: 40, Description: { Text: 'Justify the filter parameters and show your calculations.' } },
|
|
27
|
+
{ Name: 'Validation', Points: 40, Description: { Text: 'Include labelled frequency response plots and discuss the results.' } },
|
|
28
|
+
{ Name: 'Presentation', Points: 20, Description: { Text: 'Use a readable report with reproducible MATLAB code.' } },
|
|
29
|
+
] }]), key: '/d2l/api/le/1.93/103/rubrics/?objectType=Dropbox&objectId=202' },
|
|
30
|
+
{ ...row('assignment_file', '103', { text: 'Use a 1 kHz cutoff frequency and a 10 kHz sampling rate. Include the magnitude and phase response.' }),
|
|
31
|
+
key: '/d2l/api/le/1.93/103/dropbox/folders/202/attachments/7' },
|
|
32
|
+
{ ...row('own_submissions', '103', []), key: '/d2l/api/le/1.93/103/dropbox/folders/202/submissions/mysubmissions/' },
|
|
33
|
+
row('content', '103', { Title: 'Frequency response', Description: { Text: 'Calculate and plot magnitude and phase. Compare the response against the filter specifications.' } }),
|
|
34
|
+
row('announcements', '103', [{ Title: 'Assignment 2 clarification', Body: { Text: 'For Assignment 2, include both magnitude and phase plots in your report.' } }]),
|
|
19
35
|
], { state: 'demo', updatedAt: stamp }), scope: 'demo' };
|
|
20
36
|
}
|
package/src/ui-server.mjs
CHANGED
|
@@ -6,6 +6,11 @@ import { parseArgs } from 'node:util';
|
|
|
6
6
|
import { readInventory, searchInventory, homeInventory } from './ui-data.mjs';
|
|
7
7
|
import { demoInventory } from './ui-demo.mjs';
|
|
8
8
|
import { ChatGPT } from './llm.mjs';
|
|
9
|
+
import { assignmentWorkspace } from './assignment-workspace.mjs';
|
|
10
|
+
import { composeTaskWorkspace, validTaskContext, taskCatalogue } from './task-workspace.mjs';
|
|
11
|
+
import { validCanvasContext, validCanvas } from '../ui/canvas-contract.js';
|
|
12
|
+
import { canvasDocument } from './canvas.mjs';
|
|
13
|
+
import { createResearch } from './research.mjs';
|
|
9
14
|
|
|
10
15
|
const root = fileURLToPath(new URL('../ui/', import.meta.url));
|
|
11
16
|
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.png': 'image/png' };
|
|
@@ -28,14 +33,14 @@ ${connected ? '<a class="auth-usage" href="https://chatgpt.com/settings/usage" t
|
|
|
28
33
|
</main></body></html>`;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
export function createUiServer({ demo = false, inventoryPath = join(process.env.LOCALAPPDATA || '', 'dd2l', 'inventory.sqlite'),
|
|
36
|
+
export function createUiServer({ demo = false, connector, shutdownSignal, inventoryPath = connector?.store.path || join(process.env.LOCALAPPDATA || '', 'dd2l', 'inventory.sqlite'),
|
|
32
37
|
llm = demo ? null : new ChatGPT(join(process.env.LOCALAPPDATA || '', 'dd2l')) } = {}) {
|
|
33
38
|
return createServer(async (req, res) => {
|
|
34
39
|
const authority = `127.0.0.1:${res.socket.localPort}`;
|
|
35
|
-
const send = (status, body, type = 'application/json') => {
|
|
40
|
+
const send = (status, body, type = 'application/json', policy) => {
|
|
36
41
|
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
37
|
-
'Referrer-Policy': 'no-referrer', 'Cross-Origin-Resource-Policy': 'same-origin', 'X-Frame-Options': 'DENY',
|
|
38
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" });
|
|
42
|
+
'Referrer-Policy': 'no-referrer', 'Cross-Origin-Resource-Policy': 'same-origin', 'X-Frame-Options': policy ? 'SAMEORIGIN' : 'DENY',
|
|
43
|
+
'Content-Security-Policy': policy || "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" });
|
|
39
44
|
res.end(type === 'application/json' ? JSON.stringify(body) : body);
|
|
40
45
|
};
|
|
41
46
|
const callback = req.method === 'GET' && req.url.startsWith('/auth/callback?');
|
|
@@ -62,7 +67,7 @@ export function createUiServer({ demo = false, inventoryPath = join(process.env.
|
|
|
62
67
|
let bytes = 0;
|
|
63
68
|
for await (const chunk of req) {
|
|
64
69
|
bytes += chunk.length;
|
|
65
|
-
if (bytes > 8192) { send(413, { error: 'Request too large.' }); return; }
|
|
70
|
+
if (bytes > (url.pathname === '/api/llm/answer' ? 1_000_000 : 8192)) { send(413, { error: 'Request too large.' }); return; }
|
|
66
71
|
chunks.push(chunk);
|
|
67
72
|
}
|
|
68
73
|
let data;
|
|
@@ -73,39 +78,69 @@ export function createUiServer({ demo = false, inventoryPath = join(process.env.
|
|
|
73
78
|
else if (url.pathname === '/api/llm/select') send(200, await llm.select(data.id));
|
|
74
79
|
else if (url.pathname === '/api/llm/logout') send(200, await llm.logout());
|
|
75
80
|
else if (url.pathname === '/api/llm/answer') {
|
|
76
|
-
const { q, course = '', model, consent } = data;
|
|
81
|
+
const { q, course = '', assignment = '', context = null, canvas = null, model, consent } = data;
|
|
77
82
|
if (typeof q !== 'string' || !q.trim() || q.length > 500 || typeof course !== 'string' || (course && !/^\d+$/.test(course)) || typeof model !== 'string' || !model || model.length > 200 || consent !== true) {
|
|
78
|
-
send(400, { error: 'Provide a question, course, model and permission to
|
|
83
|
+
send(400, { error: 'Provide a question, course, model and permission to share coursework and workspace progress with OpenAI.' }); return;
|
|
79
84
|
}
|
|
85
|
+
if (typeof assignment !== 'string' || (assignment && (!/^\d+$/.test(assignment) || !course))) { send(400, { error: 'Invalid assignment context.' }); return; }
|
|
86
|
+
if (!validTaskContext(context)) { send(400, { error: 'Invalid workspace context.' }); return; }
|
|
87
|
+
if (!validCanvasContext(canvas)) { send(400, { error: 'Invalid canvas state.' }); return; }
|
|
80
88
|
const inventory = readInventory(inventoryPath);
|
|
81
89
|
if (course && !inventory.courses.some(c => c.id === course)) { send(404, { error: 'Course is not available in this inventory.' }); return; }
|
|
82
|
-
const sources = searchInventory(inventory, q, course).results.slice(0, 8);
|
|
83
90
|
const controller = new AbortController();
|
|
84
91
|
const abort = () => controller.abort();
|
|
85
92
|
res.on('close', abort);
|
|
86
|
-
try {
|
|
93
|
+
try {
|
|
94
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(300_000), ...(shutdownSignal ? [shutdownSignal] : [])]);
|
|
95
|
+
const research = createResearch({ connector, inventoryPath, scope: course, accountScope: inventory.scope || '', signal });
|
|
96
|
+
const task = await composeTaskWorkspace(llm, inventory, q, model, course, context, signal,
|
|
97
|
+
() => readInventory(inventoryPath), research);
|
|
98
|
+
if (task.workspace.question) { send(200, task); return; }
|
|
99
|
+
try {
|
|
100
|
+
const generated = await llm.generateCanvas(`Goal: ${task.workspace.goal}\nCurrent request: ${q}`, model, task.sources, canvas, signal);
|
|
101
|
+
if (!validCanvas(generated)) throw new Error('Invalid generated canvas');
|
|
102
|
+
send(200, { canvas: generated, answer: generated.answer, ...task });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (controller.signal.aborted) throw error;
|
|
105
|
+
send(200, { ...task, answerWarning: error.public ? error.message : 'AI explanation is unavailable. Your selected sources are available below.' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
87
108
|
finally { res.off('close', abort); }
|
|
88
109
|
} else send(404, { error: 'Not found.' });
|
|
89
110
|
return;
|
|
90
111
|
}
|
|
91
112
|
if (req.method !== 'GET') { send(405, { error: 'This route is read-only.' }); return; }
|
|
92
113
|
if (url.pathname.startsWith('/api/')) {
|
|
93
|
-
if (!['/api/status', '/api/courses', '/api/search', '/api/home'].includes(url.pathname)) { send(404, { error: 'Not found.' }); return; }
|
|
114
|
+
if (!['/api/status', '/api/courses', '/api/search', '/api/home', '/api/source'].includes(url.pathname)) { send(404, { error: 'Not found.' }); return; }
|
|
94
115
|
const query = url.searchParams.get('q') || '';
|
|
95
116
|
const course = url.searchParams.get('course') || '';
|
|
117
|
+
const assignment = url.searchParams.get('assignment') || '';
|
|
118
|
+
if (assignment && (!/^\d+$/.test(assignment) || !course)) { send(400, { error: 'Invalid assignment context.' }); return; }
|
|
96
119
|
if (query.length > 500 || (course && !/^\d+$/.test(course))) { send(400, { error: 'Use a query of at most 500 characters and a valid course.' }); return; }
|
|
97
120
|
const inventory = demo ? demoInventory() : readInventory(inventoryPath);
|
|
98
121
|
if (course && !inventory.courses.some(item => item.id === course)) { send(404, { error: 'Course is not available in this inventory.' }); return; }
|
|
99
|
-
if (url.pathname === '/api/
|
|
122
|
+
if (url.pathname === '/api/source') {
|
|
123
|
+
const id = url.searchParams.get('id') || '';
|
|
124
|
+
if (!/^[\w-]{24}$/.test(id)) { send(400, { error: 'Invalid source reference.' }); return; }
|
|
125
|
+
const source = taskCatalogue(inventory, course).find(source => source.id === id);
|
|
126
|
+
if (!source) { send(404, { error: 'This source is no longer available. Refresh the workspace.' }); return; }
|
|
127
|
+
send(200, { id: source.id, title: source.title, courseName: source.courseName, text: source.text.slice(0, 100_000),
|
|
128
|
+
url: source.url, stale: !!source.stale, lastSuccess: source.lastSuccess || null });
|
|
129
|
+
}
|
|
130
|
+
else if (url.pathname === '/api/status') send(200, { demo, state: inventory.state,
|
|
100
131
|
courseCount: inventory.courses.length, sourceCount: inventory.sources.length,
|
|
101
132
|
lastSuccess: inventory.sources.map(source => source.lastSuccess).filter(Boolean).sort().at(-1) || null });
|
|
102
133
|
else if (url.pathname === '/api/home') send(200, { ...homeInventory(inventory), demo });
|
|
103
134
|
else if (url.pathname === '/api/courses') send(200, { courses: inventory.courses });
|
|
104
|
-
else send(200, { ...searchInventory(inventory, query, course), demo });
|
|
135
|
+
else send(200, { ...searchInventory(inventory, query, course), workspace: query ? assignmentWorkspace(inventory, query, course, assignment) : null, demo });
|
|
105
136
|
return;
|
|
106
137
|
}
|
|
138
|
+
if (url.pathname === '/canvas-frame') {
|
|
139
|
+
const frame = canvasDocument();
|
|
140
|
+
send(200, frame.html, 'text/html; charset=utf-8', frame.policy); return;
|
|
141
|
+
}
|
|
107
142
|
const path = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
|
|
108
|
-
if (!/^(?:index\.html|app\.js|widgets\.js|widget-layout\.js|answer-view\.js|answer-renderer\.js|loading\.js|style\.css|assets\/[a-z-]+\.(?:svg|png))$/.test(path)) { send(404, { error: 'Not found.' }); return; }
|
|
143
|
+
if (!/^(?:index\.html|app\.js|canvas\.js|canvas-contract\.js|widgets\.js|widget-layout\.js|assignment-workspace\.js|answer-view\.js|answer-renderer\.js|loading\.js|style\.css|assets\/[a-z-]+\.(?:svg|png))$/.test(path)) { send(404, { error: 'Not found.' }); return; }
|
|
109
144
|
send(200, await readFile(join(root, path)), mime[extname(path)]);
|
|
110
145
|
} catch (error) {
|
|
111
146
|
send(error.code === 'ENOENT' ? 404 : 503, { error: error.public ? error.message : error.code === 'ENOENT' ? 'Not found.' : 'Local data or AI service is unavailable. Retry after the current operation finishes.' });
|
|
@@ -118,8 +153,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
118
153
|
const port = Number(values.port);
|
|
119
154
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Port must be between 1024 and 65535.');
|
|
120
155
|
if (!values.demo && !process.env.LOCALAPPDATA) throw new Error('Live inventory requires Windows LOCALAPPDATA. Use --demo for sample data.');
|
|
121
|
-
const
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const server = createUiServer({ demo: values.demo, shutdownSignal: controller.signal });
|
|
122
158
|
server.on('error', error => { console.error(`Could not start UI: ${error.code}`); process.exitCode = 1; });
|
|
123
159
|
server.listen(port, '127.0.0.1', () => console.log(`dd2l ${values.demo ? 'DEMO (sample data)' : '(local cached coursework)'}: http://127.0.0.1:${port}`));
|
|
124
|
-
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
|
|
160
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { controller.abort(); server.close(); server.closeAllConnections(); });
|
|
125
161
|
}
|
package/src/windows-ocr.ps1
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
param([Parameter(Mandatory=$true)][string]$ImagePath)
|
|
2
|
-
$ErrorActionPreference = 'Stop'
|
|
3
|
-
try {
|
|
4
|
-
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
5
|
-
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType=WindowsRuntime]
|
|
6
|
-
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
|
|
7
|
-
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
|
8
|
-
$asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.IsGenericMethod -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' } | Select-Object -First 1
|
|
9
|
-
function Await-Result($operation, $resultType) {
|
|
10
|
-
$task = $asTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
|
|
11
|
-
$task.Wait()
|
|
12
|
-
$task.Result
|
|
13
|
-
}
|
|
14
|
-
$file = Await-Result ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile])
|
|
15
|
-
$stream = Await-Result ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
|
|
16
|
-
try {
|
|
17
|
-
$decoder = Await-Result ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
|
|
18
|
-
if ([double]$decoder.PixelWidth * $decoder.PixelHeight -gt 16000000) { throw 'Image pixel limit exceeded' }
|
|
19
|
-
$maximum = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension
|
|
20
|
-
$scale = [Math]::Min(1.0, $maximum / [double][Math]::Max($decoder.PixelWidth, $decoder.PixelHeight))
|
|
21
|
-
$transform = [Windows.Graphics.Imaging.BitmapTransform]::new()
|
|
22
|
-
$transform.ScaledWidth = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelWidth * $scale))
|
|
23
|
-
$transform.ScaledHeight = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelHeight * $scale))
|
|
24
|
-
$bitmap = Await-Result ($decoder.GetSoftwareBitmapAsync([Windows.Graphics.Imaging.BitmapPixelFormat]::Bgra8, [Windows.Graphics.Imaging.BitmapAlphaMode]::Ignore, $transform, [Windows.Graphics.Imaging.ExifOrientationMode]::RespectExifOrientation, [Windows.Graphics.Imaging.ColorManagementMode]::DoNotColorManage)) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
|
25
|
-
try {
|
|
26
|
-
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
|
27
|
-
if ($null -eq $engine) { throw 'OCR language unavailable' }
|
|
28
|
-
$result = Await-Result ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
|
|
29
|
-
[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($result.Text)))
|
|
30
|
-
} finally { $bitmap.Dispose() }
|
|
31
|
-
} finally { $stream.Dispose() }
|
|
32
|
-
} catch { exit 1 }
|
|
1
|
+
param([Parameter(Mandatory=$true)][string]$ImagePath)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
try {
|
|
4
|
+
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
5
|
+
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType=WindowsRuntime]
|
|
6
|
+
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
|
|
7
|
+
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
|
8
|
+
$asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.IsGenericMethod -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' } | Select-Object -First 1
|
|
9
|
+
function Await-Result($operation, $resultType) {
|
|
10
|
+
$task = $asTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
|
|
11
|
+
$task.Wait()
|
|
12
|
+
$task.Result
|
|
13
|
+
}
|
|
14
|
+
$file = Await-Result ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile])
|
|
15
|
+
$stream = Await-Result ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
|
|
16
|
+
try {
|
|
17
|
+
$decoder = Await-Result ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
|
|
18
|
+
if ([double]$decoder.PixelWidth * $decoder.PixelHeight -gt 16000000) { throw 'Image pixel limit exceeded' }
|
|
19
|
+
$maximum = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension
|
|
20
|
+
$scale = [Math]::Min(1.0, $maximum / [double][Math]::Max($decoder.PixelWidth, $decoder.PixelHeight))
|
|
21
|
+
$transform = [Windows.Graphics.Imaging.BitmapTransform]::new()
|
|
22
|
+
$transform.ScaledWidth = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelWidth * $scale))
|
|
23
|
+
$transform.ScaledHeight = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelHeight * $scale))
|
|
24
|
+
$bitmap = Await-Result ($decoder.GetSoftwareBitmapAsync([Windows.Graphics.Imaging.BitmapPixelFormat]::Bgra8, [Windows.Graphics.Imaging.BitmapAlphaMode]::Ignore, $transform, [Windows.Graphics.Imaging.ExifOrientationMode]::RespectExifOrientation, [Windows.Graphics.Imaging.ColorManagementMode]::DoNotColorManage)) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
|
25
|
+
try {
|
|
26
|
+
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
|
27
|
+
if ($null -eq $engine) { throw 'OCR language unavailable' }
|
|
28
|
+
$result = Await-Result ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
|
|
29
|
+
[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($result.Text)))
|
|
30
|
+
} finally { $bitmap.Dispose() }
|
|
31
|
+
} finally { $stream.Dispose() }
|
|
32
|
+
} catch { exit 1 }
|