@ivanbard/dd2l 0.0.0-stage → 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 -0
- package/README.md +122 -3
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/bin/dd2l.mjs +7 -0
- package/docs/README.md +227 -0
- package/package.json +56 -6
- package/src/app-runner.mjs +58 -0
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +142 -0
- package/src/client.mjs +150 -0
- package/src/connector.mjs +587 -0
- package/src/discovery.mjs +45 -0
- package/src/documents.mjs +154 -0
- package/src/links.mjs +99 -0
- package/src/llm.mjs +243 -0
- package/src/media.mjs +54 -0
- package/src/ocr.mjs +24 -0
- package/src/office.mjs +61 -0
- package/src/pdf-worker.mjs +26 -0
- package/src/research.mjs +70 -0
- package/src/session.mjs +142 -0
- package/src/store.mjs +108 -0
- package/src/syllabus.mjs +191 -0
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +123 -0
- package/src/ui-demo.mjs +36 -0
- package/src/ui-server.mjs +161 -0
- package/src/windows-ocr.ps1 +32 -0
- package/ui/answer-renderer.js +88 -0
- package/ui/answer-view.js +47 -0
- package/ui/app.js +385 -0
- package/ui/assets/LUCIDE-LICENSE.txt +15 -0
- package/ui/assets/avatar.svg +3 -0
- package/ui/assets/chevron.svg +5 -0
- package/ui/assets/courses.svg +14 -0
- package/ui/assets/logo.svg +13 -0
- package/ui/assets/new-chat.svg +14 -0
- package/ui/assets/search.svg +14 -0
- package/ui/assets/send.svg +8 -0
- package/ui/assets/settings.svg +14 -0
- package/ui/assets/widget-announcements.svg +4 -0
- package/ui/assets/widget-shortcuts.svg +5 -0
- 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 +84 -0
- package/ui/loading.js +33 -0
- package/ui/style.css +241 -0
- package/ui/widget-layout.js +39 -0
- package/ui/widgets.js +252 -0
|
@@ -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
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { htmlContent } from './documents.mjs';
|
|
3
|
+
import { SCHOOL, schoolOrigin } from './client.mjs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { projectAssignments } from './assignment-workspace.mjs';
|
|
6
|
+
|
|
7
|
+
const categories = new Set(['content', 'overview', 'syllabus_source', 'assignments', 'quizzes', 'announcements',
|
|
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']);
|
|
11
|
+
const textFields = new Set(['Title', 'Name', 'Subject', 'Text', 'Html', 'text', 'Description', 'Instructions',
|
|
12
|
+
'Body', 'Content', 'StartDate', 'EndDate', 'DueDate', 'StartDateTime', 'EndDateTime',
|
|
13
|
+
'DisplayedGrade', 'PointsNumerator', 'PointsDenominator', 'WeightedNumerator', 'WeightedDenominator',
|
|
14
|
+
'Feedback', 'FeedbackText', 'Score', 'Points', 'MaxPoints', 'CompletionDate', 'TimeLimit']);
|
|
15
|
+
|
|
16
|
+
// Open for each request so logout, revoked access and new syncs are reflected immediately.
|
|
17
|
+
export function readInventory(path) {
|
|
18
|
+
let db;
|
|
19
|
+
try {
|
|
20
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
21
|
+
db.exec('PRAGMA busy_timeout=3000; BEGIN');
|
|
22
|
+
const meta = key => {
|
|
23
|
+
const row = db.prepare('SELECT value FROM meta WHERE key=?').get(key);
|
|
24
|
+
return row ? JSON.parse(row.value) : null;
|
|
25
|
+
};
|
|
26
|
+
const state = meta('state');
|
|
27
|
+
const rows = db.prepare(`SELECT key,course,category,data,last_success,checked_at,outcome
|
|
28
|
+
FROM resources`).all()
|
|
29
|
+
.map(row => ({ ...row, data: JSON.parse(row.data) }));
|
|
30
|
+
const account = meta('account');
|
|
31
|
+
return { ...projectInventory(rows, state, meta('school_origin') ?? SCHOOL), scope: account ? createHash('sha256').update(account).digest('hex') : null };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ERR_SQLITE_ERROR' && /unable to open database/.test(error.message)) {
|
|
34
|
+
return { courses: [], sources: [], state: null };
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
} finally { db?.close(); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function projectInventory(rows, state, origin = SCHOOL) {
|
|
41
|
+
origin = schoolOrigin(origin);
|
|
42
|
+
const enrollment = rows.find(row => row.category === 'enrollments' && !['denied', 'not_configured'].includes(row.outcome));
|
|
43
|
+
const courses = (enrollment?.data ?? []).filter(item => item.Access?.CanAccess === true && item.OrgUnit?.Id)
|
|
44
|
+
.map(item => ({ id: String(item.OrgUnit.Id), name: item.OrgUnit.Name || `Course ${item.OrgUnit.Id}`,
|
|
45
|
+
active: item.Access.IsActive === true }));
|
|
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));
|
|
51
|
+
const sources = [];
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
if (!categories.has(row.category) || !allowed.has(String(row.course)) || ['denied', 'not_configured'].includes(row.outcome)) continue;
|
|
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 = '$') => {
|
|
59
|
+
if (!value || typeof value !== 'object' || depth > 15) 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
|
+
}
|
|
67
|
+
if (value.IsHidden || value.IsLocked || value.IsDeleted || value.IsPublished === false ||
|
|
68
|
+
Date.parse(value.StartDateTime) > Date.now() || Date.parse(value.EndDateTime) < Date.now() ||
|
|
69
|
+
(row.category === 'announcements' && (Date.parse(value.StartDate) > Date.now() || Date.parse(value.EndDate) < Date.now()))) return;
|
|
70
|
+
const title = String(value.Title || value.Name || value.Subject || value.title || parentTitle || row.category.replaceAll('_', ' '));
|
|
71
|
+
const parts = [];
|
|
72
|
+
const collect = (item, field, level = 0) => {
|
|
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);
|
|
75
|
+
else if (item && typeof item === 'object' && !Array.isArray(item) && level < 3) {
|
|
76
|
+
for (const [key, child] of Object.entries(item)) if (textFields.has(key)) collect(child, key, level + 1);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
for (const [key, child] of Object.entries(value)) if (textFields.has(key)) collect(child, key);
|
|
80
|
+
const text = [...new Set(parts.filter(Boolean))].join('\n').slice(0, 1_000_000);
|
|
81
|
+
if (text) sources.push({ id: `${row.key}#${path}`, resourceKey: row.key, course: String(row.course),
|
|
82
|
+
courseName: allowed.get(String(row.course)).name, title, text, category: row.category,
|
|
83
|
+
lastSuccess: row.last_success, checkedAt: row.checked_at, stale: row.outcome !== 'available',
|
|
84
|
+
dueDate: ['assignments', 'quizzes', 'quiz_details', 'calendar', 'calendar_occurrences'].includes(row.category) && Number.isFinite(Date.parse(value.DueDate)) ? value.DueDate : null,
|
|
85
|
+
publishedAt: row.category === 'announcements' ? value.PublishedDate || value.StartDate || null : null,
|
|
86
|
+
url: `${origin}/d2l/home/${row.course}` });
|
|
87
|
+
for (const [key, child] of Object.entries(value)) {
|
|
88
|
+
if (!textFields.has(key) && child && typeof child === 'object') visit(child, title, depth + 1, `${path}/${key}`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
visit(row.data);
|
|
92
|
+
}
|
|
93
|
+
return { courses, sources, assignments, state };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function homeInventory(inventory, now = Date.now()) {
|
|
97
|
+
const sources = inventory.sources.map(({ text, ...source }) => ({ ...source, excerpt: text.slice(0, 300),
|
|
98
|
+
ref: JSON.stringify([source.course, source.category, source.title]) }));
|
|
99
|
+
return { scope: inventory.scope || 'local-empty', courses: inventory.courses,
|
|
100
|
+
deadlines: sources.filter(source => Date.parse(source.dueDate) >= now).sort((a, b) => Date.parse(a.dueDate) - Date.parse(b.dueDate)),
|
|
101
|
+
announcements: sources.filter(source => source.category === 'announcements').sort((a, b) => (Date.parse(b.publishedAt) || 0) - (Date.parse(a.publishedAt) || 0)),
|
|
102
|
+
sources };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stopWords = new Set('a an the what when where which who how are is was were do does did i my me we our you your for of to in on at and or about can could would should please find show tell looking today'.split(' '));
|
|
106
|
+
|
|
107
|
+
export function searchInventory(inventory, query, course = '') {
|
|
108
|
+
// shortcut: local keyword retrieval only; replace with grounded prompt retrieval when that service exists.
|
|
109
|
+
const terms = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])].filter(term => !stopWords.has(term));
|
|
110
|
+
if (query.trim() && !terms.length) return { results: [], total: 0 };
|
|
111
|
+
const matches = inventory.sources.filter(source => !course || source.course === course).map(source => {
|
|
112
|
+
const title = `${source.title} ${source.courseName}`.toLowerCase();
|
|
113
|
+
const body = source.text.toLowerCase();
|
|
114
|
+
return { source, score: terms.reduce((score, term) => score + (title.includes(term) ? 3 : body.includes(term) ? 1 : 0), 0) };
|
|
115
|
+
}).filter(item => !terms.length || item.score > 0).sort((a, b) => b.score - a.score);
|
|
116
|
+
return { total: matches.length, results: matches.slice(0, 30).map(({ source }) => {
|
|
117
|
+
const lower = source.text.toLowerCase();
|
|
118
|
+
const first = terms.map(term => lower.indexOf(term)).filter(index => index >= 0);
|
|
119
|
+
const start = Math.max(0, (first.length ? Math.min(...first) : 0) - 100);
|
|
120
|
+
const { text, ...metadata } = source;
|
|
121
|
+
return { ...metadata, excerpt: `${start ? '…' : ''}${text.slice(start, start + 700)}${text.length > start + 700 ? '…' : ''}` };
|
|
122
|
+
}) };
|
|
123
|
+
}
|
package/src/ui-demo.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { projectInventory } from './ui-data.mjs';
|
|
2
|
+
|
|
3
|
+
export function demoInventory() {
|
|
4
|
+
const stamp = '2026-11-02T14:00:00.000Z';
|
|
5
|
+
const row = (category, course, data) => ({ key: `demo:${category}:${course}`, category, course,
|
|
6
|
+
data, outcome: 'available', last_success: stamp, checked_at: stamp });
|
|
7
|
+
return { ...projectInventory([
|
|
8
|
+
row('enrollments', null, [
|
|
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 } },
|
|
11
|
+
{ OrgUnit: { Id: 102, Name: 'PSYC 100 · Principles of Psychology' }, Access: { CanAccess: true, IsActive: true } },
|
|
12
|
+
]),
|
|
13
|
+
row('assignments', '101', [{ Id: 201, Name: 'Assignment 3 · Recursion', DueDate: '2026-11-09T23:59:00-05:00',
|
|
14
|
+
Instructions: { Text: 'Implement and test recursive functions. Submit your Python file and a short explanation through onQ. Review the recursion lecture before starting.' } }]),
|
|
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.' }),
|
|
16
|
+
row('content', '102', { Modules: [{ Title: 'Week 8 · Memory', Topics: [
|
|
17
|
+
{ Title: 'Memory and retrieval', Description: { Text: 'Encoding, working memory, long-term memory, and retrieval practice. Read chapter 8 before the seminar.' } },
|
|
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.' } }]),
|
|
35
|
+
], { state: 'demo', updatedAt: stamp }), scope: 'demo' };
|
|
36
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { readInventory, searchInventory, homeInventory } from './ui-data.mjs';
|
|
7
|
+
import { demoInventory } from './ui-demo.mjs';
|
|
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';
|
|
14
|
+
|
|
15
|
+
const root = fileURLToPath(new URL('../ui/', import.meta.url));
|
|
16
|
+
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.png': 'image/png' };
|
|
17
|
+
|
|
18
|
+
function signInPage(connected, first = false) {
|
|
19
|
+
const title = connected ? (first ? "You're using your ChatGPT plan" : 'ChatGPT connected') : 'Sign-in did not complete';
|
|
20
|
+
const message = connected
|
|
21
|
+
? 'Eligible AI requests use your ChatGPT plan. You can manage limits in ChatGPT Settings → Usage.'
|
|
22
|
+
: 'Return to dd2l and open Settings to try signing in again.';
|
|
23
|
+
return `<!doctype html>
|
|
24
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
25
|
+
<meta name="color-scheme" content="dark"><title>${title} · dd2l</title>
|
|
26
|
+
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml"><link rel="stylesheet" href="/style.css"></head>
|
|
27
|
+
<body class="auth-page"><main class="auth-content" aria-labelledby="auth-heading">
|
|
28
|
+
<a class="auth-brand" href="/" aria-label="dd2l home"><img src="/assets/logo.svg" width="40" height="40" alt=""><span>dd2l</span></a>
|
|
29
|
+
<p class="auth-status">${connected ? 'Account connected' : 'Connection interrupted'}</p>
|
|
30
|
+
<h1 id="auth-heading">${title}</h1><p class="auth-message">${message}</p>
|
|
31
|
+
<a class="auth-return" href="/">${connected && first ? 'Got it — return to dd2l' : 'Return to dd2l'} <span aria-hidden="true">→</span></a>
|
|
32
|
+
${connected ? '<a class="auth-usage" href="https://chatgpt.com/settings/usage" target="_blank" rel="noopener noreferrer">Manage usage in ChatGPT ↗</a>' : ''}
|
|
33
|
+
</main></body></html>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createUiServer({ demo = false, connector, shutdownSignal, inventoryPath = connector?.store.path || join(process.env.LOCALAPPDATA || '', 'dd2l', 'inventory.sqlite'),
|
|
37
|
+
llm = demo ? null : new ChatGPT(join(process.env.LOCALAPPDATA || '', 'dd2l')) } = {}) {
|
|
38
|
+
return createServer(async (req, res) => {
|
|
39
|
+
const authority = `127.0.0.1:${res.socket.localPort}`;
|
|
40
|
+
const send = (status, body, type = 'application/json', policy) => {
|
|
41
|
+
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
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'" });
|
|
44
|
+
res.end(type === 'application/json' ? JSON.stringify(body) : body);
|
|
45
|
+
};
|
|
46
|
+
const callback = req.method === 'GET' && req.url.startsWith('/auth/callback?');
|
|
47
|
+
if (req.headers.host !== authority || (!callback && ((req.headers.origin && req.headers.origin !== `http://${authority}`) || req.headers['sec-fetch-site'] === 'cross-site'))) {
|
|
48
|
+
send(403, { error: 'Open dd2l using its local URL.' }); return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(req.url, `http://${authority}`);
|
|
52
|
+
if (callback) {
|
|
53
|
+
try {
|
|
54
|
+
if (!llm || demo) throw new Error('disabled');
|
|
55
|
+
const result = await llm.callback(url.searchParams);
|
|
56
|
+
send(200, signInPage(true, result.first), 'text/html; charset=utf-8');
|
|
57
|
+
} catch { send(400, signInPage(false), 'text/html; charset=utf-8'); }
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (url.pathname.startsWith('/api/llm/')) {
|
|
61
|
+
if (!llm || demo) { send(200, { disabled: true, accounts: [], active: null }); return; }
|
|
62
|
+
if (req.method === 'GET' && url.pathname === '/api/llm/status') { send(200, await llm.status()); return; }
|
|
63
|
+
if (req.method === 'GET' && url.pathname === '/api/llm/models') { send(200, await llm.models()); return; }
|
|
64
|
+
if (req.method !== 'POST') { send(405, { error: 'Use POST for AI actions.' }); return; }
|
|
65
|
+
if (req.headers.origin !== `http://${authority}` || !/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) { send(403, { error: 'AI actions require a local JSON request.' }); return; }
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
for await (const chunk of req) {
|
|
69
|
+
bytes += chunk.length;
|
|
70
|
+
if (bytes > (url.pathname === '/api/llm/answer' ? 1_000_000 : 8192)) { send(413, { error: 'Request too large.' }); return; }
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
}
|
|
73
|
+
let data;
|
|
74
|
+
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { send(400, { error: 'Invalid JSON.' }); return; }
|
|
75
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) { send(400, { error: 'Invalid request.' }); return; }
|
|
76
|
+
if (['login', 'select'].includes(url.pathname.split('/').at(-1)) && data.id !== undefined && (typeof data.id !== 'string' || data.id.length > 300)) { send(400, { error: 'Invalid account.' }); return; }
|
|
77
|
+
if (url.pathname === '/api/llm/login') send(200, await llm.login(`http://${authority}/auth/callback`, data.id));
|
|
78
|
+
else if (url.pathname === '/api/llm/select') send(200, await llm.select(data.id));
|
|
79
|
+
else if (url.pathname === '/api/llm/logout') send(200, await llm.logout());
|
|
80
|
+
else if (url.pathname === '/api/llm/answer') {
|
|
81
|
+
const { q, course = '', assignment = '', context = null, canvas = null, model, consent } = data;
|
|
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) {
|
|
83
|
+
send(400, { error: 'Provide a question, course, model and permission to share coursework and workspace progress with OpenAI.' }); return;
|
|
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; }
|
|
88
|
+
const inventory = readInventory(inventoryPath);
|
|
89
|
+
if (course && !inventory.courses.some(c => c.id === course)) { send(404, { error: 'Course is not available in this inventory.' }); return; }
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const abort = () => controller.abort();
|
|
92
|
+
res.on('close', abort);
|
|
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
|
+
}
|
|
108
|
+
finally { res.off('close', abort); }
|
|
109
|
+
} else send(404, { error: 'Not found.' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (req.method !== 'GET') { send(405, { error: 'This route is read-only.' }); return; }
|
|
113
|
+
if (url.pathname.startsWith('/api/')) {
|
|
114
|
+
if (!['/api/status', '/api/courses', '/api/search', '/api/home', '/api/source'].includes(url.pathname)) { send(404, { error: 'Not found.' }); return; }
|
|
115
|
+
const query = url.searchParams.get('q') || '';
|
|
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; }
|
|
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; }
|
|
120
|
+
const inventory = demo ? demoInventory() : readInventory(inventoryPath);
|
|
121
|
+
if (course && !inventory.courses.some(item => item.id === course)) { send(404, { error: 'Course is not available in this inventory.' }); return; }
|
|
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,
|
|
131
|
+
courseCount: inventory.courses.length, sourceCount: inventory.sources.length,
|
|
132
|
+
lastSuccess: inventory.sources.map(source => source.lastSuccess).filter(Boolean).sort().at(-1) || null });
|
|
133
|
+
else if (url.pathname === '/api/home') send(200, { ...homeInventory(inventory), demo });
|
|
134
|
+
else if (url.pathname === '/api/courses') send(200, { courses: inventory.courses });
|
|
135
|
+
else send(200, { ...searchInventory(inventory, query, course), workspace: query ? assignmentWorkspace(inventory, query, course, assignment) : null, demo });
|
|
136
|
+
return;
|
|
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
|
+
}
|
|
142
|
+
const path = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
|
|
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; }
|
|
144
|
+
send(200, await readFile(join(root, path)), mime[extname(path)]);
|
|
145
|
+
} catch (error) {
|
|
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.' });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
152
|
+
const { values } = parseArgs({ options: { demo: { type: 'boolean' }, port: { type: 'string', default: '4173' } } });
|
|
153
|
+
const port = Number(values.port);
|
|
154
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Port must be between 1024 and 65535.');
|
|
155
|
+
if (!values.demo && !process.env.LOCALAPPDATA) throw new Error('Live inventory requires Windows LOCALAPPDATA. Use --demo for sample data.');
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const server = createUiServer({ demo: values.demo, shutdownSignal: controller.signal });
|
|
158
|
+
server.on('error', error => { console.error(`Could not start UI: ${error.code}`); process.exitCode = 1; });
|
|
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}`));
|
|
160
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { controller.abort(); server.close(); server.closeAllConnections(); });
|
|
161
|
+
}
|
|
@@ -0,0 +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 }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { validAnswerView } from './answer-view.js';
|
|
2
|
+
|
|
3
|
+
const element = (tag, text, className) => {
|
|
4
|
+
const node = document.createElement(tag);
|
|
5
|
+
if (text !== undefined) node.textContent = text;
|
|
6
|
+
if (className) node.className = className;
|
|
7
|
+
return node;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function references(ids, sources) {
|
|
11
|
+
const links = element('div', undefined, 'answer-references');
|
|
12
|
+
for (const id of [...new Set(ids)]) {
|
|
13
|
+
const source = sources[id - 1];
|
|
14
|
+
const link = element('a', `[${id}]${source.stale ? ' · Stale cache' : ''}`);
|
|
15
|
+
link.href = `#answer-source-${id}`;
|
|
16
|
+
link.setAttribute('aria-label', `Source ${id}: ${source.title}${source.stale ? ', stale cache' : ''}`);
|
|
17
|
+
links.append(link);
|
|
18
|
+
}
|
|
19
|
+
return links;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderAnswerView(target, view, sources) {
|
|
23
|
+
target.replaceChildren();
|
|
24
|
+
if (!validAnswerView(view, sources.length)) return false;
|
|
25
|
+
for (const section of view.sections) {
|
|
26
|
+
const card = element('article', undefined, `widget generated-card answer-${section.type}`);
|
|
27
|
+
const edition = element('div', undefined, 'widget-edition');
|
|
28
|
+
edition.append(element('span', { checklist: 'STUDY / CHECKLIST', comparison: 'COURSEWORK / COMPARE', cards: 'SOURCES / COLLECTION' }[section.type]), element('span', 'dd2l'));
|
|
29
|
+
const header = element('header', undefined, 'widget-header');
|
|
30
|
+
header.append(element('h3', section.title));
|
|
31
|
+
card.append(edition, header);
|
|
32
|
+
if (section.type === 'checklist') {
|
|
33
|
+
const progress = element('p', `0 of ${section.items.length} complete`, 'answer-progress');
|
|
34
|
+
progress.setAttribute('role', 'status');
|
|
35
|
+
const list = element('ul', undefined, 'answer-checklist');
|
|
36
|
+
let completed = 0;
|
|
37
|
+
for (const item of section.items) {
|
|
38
|
+
const row = element('li');
|
|
39
|
+
const label = element('label');
|
|
40
|
+
const checkbox = element('input');
|
|
41
|
+
checkbox.type = 'checkbox';
|
|
42
|
+
checkbox.addEventListener('change', () => {
|
|
43
|
+
completed += checkbox.checked ? 1 : -1;
|
|
44
|
+
row.classList.toggle('completed', checkbox.checked);
|
|
45
|
+
progress.textContent = `${completed} of ${section.items.length} complete`;
|
|
46
|
+
});
|
|
47
|
+
label.append(checkbox, element('span', item.text));
|
|
48
|
+
row.append(label, references(item.citations, sources));
|
|
49
|
+
list.append(row);
|
|
50
|
+
}
|
|
51
|
+
card.append(progress, list, element('small', 'Checks are kept for this answer only.', 'answer-session-note'));
|
|
52
|
+
} else if (section.type === 'comparison') {
|
|
53
|
+
const scroll = element('div', undefined, 'answer-table-scroll');
|
|
54
|
+
scroll.tabIndex = 0;
|
|
55
|
+
scroll.setAttribute('role', 'region');
|
|
56
|
+
scroll.setAttribute('aria-label', section.title);
|
|
57
|
+
const table = element('table');
|
|
58
|
+
table.append(element('caption', section.title, 'sr-only'));
|
|
59
|
+
const head = element('thead'), heading = element('tr');
|
|
60
|
+
for (const title of [...section.columns, 'Sources']) {
|
|
61
|
+
const cell = element('th', title); cell.scope = 'col'; heading.append(cell);
|
|
62
|
+
}
|
|
63
|
+
head.append(heading);
|
|
64
|
+
const body = element('tbody');
|
|
65
|
+
for (const row of section.rows) {
|
|
66
|
+
const tr = element('tr');
|
|
67
|
+
for (const [index, value] of row.cells.entries()) {
|
|
68
|
+
const cell = element(index ? 'td' : 'th', value);
|
|
69
|
+
if (!index) cell.scope = 'row';
|
|
70
|
+
tr.append(cell);
|
|
71
|
+
}
|
|
72
|
+
const citations = element('td'); citations.append(references(row.citations, sources)); tr.append(citations);
|
|
73
|
+
body.append(tr);
|
|
74
|
+
}
|
|
75
|
+
table.append(head, body); scroll.append(table); card.append(scroll);
|
|
76
|
+
} else {
|
|
77
|
+
const list = element('div', undefined, 'answer-source-cards');
|
|
78
|
+
for (const item of section.cards) {
|
|
79
|
+
const entry = element('section');
|
|
80
|
+
entry.append(element('h4', item.title), element('p', item.text), references(item.citations, sources));
|
|
81
|
+
list.append(entry);
|
|
82
|
+
}
|
|
83
|
+
card.append(list);
|
|
84
|
+
}
|
|
85
|
+
target.append(card);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const text = (maxLength = 1000) => ({ type: 'string', maxLength });
|
|
2
|
+
const list = (items, maxItems, minItems = 0) => ({ type: 'array', items, minItems, maxItems });
|
|
3
|
+
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
4
|
+
const kind = value => ({ type: 'string', enum: [value] });
|
|
5
|
+
const citations = list({ type: 'integer', minimum: 1, maximum: 24 }, 24, 1);
|
|
6
|
+
|
|
7
|
+
export const answerSchema = object({
|
|
8
|
+
title: text(160),
|
|
9
|
+
answer: text(6000),
|
|
10
|
+
sections: list({ anyOf: [
|
|
11
|
+
object({ type: kind('checklist'), title: text(160), items: list(object({ text: text(), citations }), 20, 1) }),
|
|
12
|
+
object({ type: kind('comparison'), title: text(160), columns: list(text(120), 5, 2),
|
|
13
|
+
rows: list(object({ cells: list(text(), 5, 2), citations }), 20, 1) }),
|
|
14
|
+
object({ type: kind('cards'), title: text(160), cards: list(object({ title: text(160), text: text(2000), citations }), 12, 1) }),
|
|
15
|
+
] }, 6),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Validate the same bounded schema we send to the provider, including unexpected keys.
|
|
19
|
+
export function matches(value, schema) {
|
|
20
|
+
if (schema.anyOf) return schema.anyOf.some(choice => matches(value, choice));
|
|
21
|
+
if (schema.type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
|
22
|
+
Object.keys(value).length === schema.required.length && schema.required.every(key => Object.hasOwn(value, key) && matches(value[key], schema.properties[key]));
|
|
23
|
+
if (schema.type === 'array') return Array.isArray(value) && value.length >= schema.minItems && value.length <= schema.maxItems && value.every(item => matches(item, schema.items));
|
|
24
|
+
if (schema.type === 'string') return typeof value === 'string' && value.length <= (schema.maxLength ?? Infinity) && (!schema.enum || schema.enum.includes(value));
|
|
25
|
+
return schema.type === 'integer' && Number.isInteger(value) && value >= schema.minimum && value <= schema.maximum;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validAnswerView(value, sourceCount) {
|
|
29
|
+
if (!matches(value, answerSchema)) return false;
|
|
30
|
+
return value.sections.every(section => {
|
|
31
|
+
const items = section.items || section.rows || section.cards;
|
|
32
|
+
return items.every(item => item.citations.every(id => id <= sourceCount) &&
|
|
33
|
+
(section.type !== 'comparison' || item.cells.length === section.columns.length));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseAnswerView(raw, sourceCount) {
|
|
38
|
+
let value;
|
|
39
|
+
try { value = JSON.parse(raw); }
|
|
40
|
+
catch {
|
|
41
|
+
if (!/^\s*(?:[\[{]|```)/.test(raw)) return { answer: raw, view: null };
|
|
42
|
+
}
|
|
43
|
+
if (validAnswerView(value, sourceCount)) return { answer: value.answer, view: value };
|
|
44
|
+
const answer = typeof value?.answer === 'string' && value.answer.trim() && value.answer.length <= 6000 ? value.answer
|
|
45
|
+
: 'The model returned an unreadable answer. Please try again.';
|
|
46
|
+
return { answer, view: null, viewWarning: 'The generated layout could not be verified. Showing text and available sources instead.' };
|
|
47
|
+
}
|