@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.
@@ -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 WHERE data IS NOT NULL AND outcome NOT IN ('denied','not_configured')`).all()
28
+ FROM resources`).all()
24
29
  .map(row => ({ ...row, data: JSON.parse(row.data) }));
25
- return { ...projectInventory(rows, state), scope: createHash('sha256').update(String(meta('account') || 'local')).digest('hex') };
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 visit = (value, parentTitle = '', depth = 0) => {
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)) { for (const item of value) visit(item, parentTitle, depth + 1); return; }
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 === 'string') parts.push(['Html', 'Description', 'Instructions', 'Body', 'Content'].includes(field) ? htmlContent(item).text : 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}#${sources.length}`, course: String(row.course),
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: `${SCHOOL}/d2l/home/${row.course}` });
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 send excerpts to OpenAI.' }); return;
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 { send(200, { ...await llm.answer(q, model, sources, AbortSignal.any([controller.signal, AbortSignal.timeout(90_000)])), sources }); }
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/status') send(200, { demo, state: inventory.state,
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 server = createUiServer({ demo: values.demo });
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
  }
@@ -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 }