Mr. Drew's Assignment Creator — Docker share build

Self-contained Dockerized build for end users. Run via docker compose;
see README.md for setup. Source-only, no sample data or build artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 19:58:36 -04:00
co-authored by Claude Opus 4.8
commit 5a51a0f112
33 changed files with 5413 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
// lib/exporter.js — zero-dependency exports, client-side only.
// Student version and teacher version (with red-pen answer key), as:
// plain text, Word (.doc via HTML), clipboard, and print (for PDF).
import { totalPoints } from "@/lib/schema";
const LETTERS = "ABCDEFGHIJ";
function answersLine(q) {
switch (q.type) {
case "multiple_choice":
return `Answer: ${LETTERS[q.correctIndex] || "?"}${q.options?.[q.correctIndex] || ""}`;
case "true_false":
return `Answer: ${q.correctAnswer ? "True" : "False"}`;
case "short_answer":
return `Sample answer: ${q.sampleAnswer || ""}` + (q.keyPoints?.length ? `\nMust include: ${q.keyPoints.join("; ")}` : "");
case "essay":
return (q.sampleResponse ? `Sample response: ${q.sampleResponse}` : "") +
(q.rubric?.length ? `\nRubric:\n${q.rubric.map((r) => `${r.criterion} (${r.points} pts)${r.description ? " — " + r.description : ""}`).join("\n")}` : "");
case "fill_blank":
return `Answers: ${(q.answers || []).map((a, i) => `(${i + 1}) ${a}`).join(" ")}`;
case "matching":
return `Answer key:\n${(q.pairs || []).map((p) => ` ${p.left}${p.right}`).join("\n")}`;
case "discussion":
return (q.talkingPoints?.length ? `Key talking points:\n${q.talkingPoints.map((t) => `${t}`).join("\n")}` : "") +
(q.followUps?.length ? `\nFollow-ups:\n${q.followUps.map((t) => `${t}`).join("\n")}` : "") +
(q.sampleResponse ? `\nA strong contribution: ${q.sampleResponse}` : "");
default:
return "";
}
}
// Shuffle the right column of a matching question deterministically (by text)
// so the student version isn't pre-matched but exports are stable.
function shuffledRight(pairs) {
return [...pairs.map((p) => p.right)].sort((a, b) => a.localeCompare(b));
}
function questionStudentText(q, n) {
const head = `${n}. ${q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question} (${q.points} pt${q.points === 1 ? "" : "s"})`;
let body = "";
if (q.type === "multiple_choice") {
body = (q.options || []).map((o, i) => ` ${LETTERS[i]}. ${o}`).join("\n");
} else if (q.type === "true_false") {
body = " True / False";
} else if (q.type === "short_answer") {
body = " ________________________________________________\n ________________________________________________";
} else if (q.type === "essay" || q.type === "discussion") {
body = "";
} else if (q.type === "matching") {
const rights = shuffledRight(q.pairs || []);
body = (q.pairs || []).map((p, i) => ` ___ ${i + 1}. ${p.left}`).join("\n") +
"\n\n" + rights.map((r, i) => ` ${LETTERS[i]}. ${r}`).join("\n");
}
return body ? head + "\n" + body : head;
}
export function buildText(assignment, { teacher, profile = {} }) {
const lines = [];
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
if (profile.schoolName) lines.push(profile.schoolName);
if (teachLine) lines.push(teachLine);
if (profile.schoolName || teachLine) lines.push("");
lines.push(assignment.title || "Untitled assignment");
const meta = [assignment.subject, assignment.gradeLevel, totalPoints(assignment.questions) + " points"].filter(Boolean).join(" · ");
lines.push(meta);
if (teacher) lines.push("TEACHER VERSION — ANSWER KEY INCLUDED");
lines.push("");
if (!teacher) lines.push("Name: ______________________________ Date: ______________", "");
if (assignment.instructions) lines.push("Instructions: " + assignment.instructions, "");
if (assignment.caseStudy) lines.push("— Read the following —", "", assignment.caseStudy, "");
assignment.questions.forEach((q, i) => {
lines.push(questionStudentText(q, i + 1));
if (teacher) {
const ans = answersLine(q);
if (ans) lines.push(" ✎ " + ans.replace(/\n/g, "\n "));
if (q.explanation) lines.push(" ✎ Explanation: " + q.explanation);
if (q.sourceRef) lines.push(" ✎ Source: \u201C" + q.sourceRef + "\u201D");
if (q.verification?.status === "warn") lines.push(" ⚠ Reviewer note: " + (q.verification.note || "flagged — double-check"));
}
lines.push("");
});
return lines.join("\n");
}
function esc(s) {
return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function schoolHeadHtml(profile = {}) {
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
if (!profile.logo && !profile.schoolName && !teachLine) return "";
return `<div class="schoolhead">
${profile.logo ? `<img class="schoollogo" src="${profile.logo}" alt="">` : ""}
<div class="schoolinfo">
${profile.schoolName ? `<div class="schoolname">${esc(profile.schoolName)}</div>` : ""}
${teachLine ? `<div class="teachline">${esc(teachLine)}</div>` : ""}
</div>
</div>`;
}
function buildBodyHtml(assignment, { teacher, profile }) {
const qs = assignment.questions || [];
const qHtml = qs.map((q, i) => {
const n = i + 1;
let body = "";
if (q.type === "multiple_choice") {
body = `<div class="opts">` + (q.options || []).map((o, j) => {
const isAns = teacher && j === q.correctIndex;
return `<div${isAns ? ' class="ans"' : ""}>${LETTERS[j]}. ${esc(o)}${isAns ? " ✓" : ""}</div>`;
}).join("") + `</div>`;
} else if (q.type === "true_false") {
body = `<div class="opts">True&nbsp;&nbsp;/&nbsp;&nbsp;False${teacher ? ` <span class="ans">✓ ${q.correctAnswer ? "True" : "False"}</span>` : ""}</div>`;
} else if (q.type === "short_answer") {
body = teacher ? "" : `<div class="writelines"></div><div class="writelines"></div>`;
} else if (q.type === "essay" || q.type === "discussion") {
body = "";
} else if (q.type === "fill_blank") {
body = "";
} else if (q.type === "matching") {
const rights = shuffledRight(q.pairs || []);
body = `<table class="match"><tr><td>` +
(q.pairs || []).map((p, j) => `<div>___ ${j + 1}. ${esc(p.left)}</div>`).join("") +
`</td><td>` + rights.map((r, j) => `<div>${LETTERS[j]}. ${esc(r)}</div>`).join("") +
`</td></tr></table>`;
}
let key = "";
if (teacher) {
const ans = answersLine(q);
key = `<div class="key">` +
(ans ? `<div>${esc(ans).replace(/\n/g, "<br>")}</div>` : "") +
(q.explanation ? `<div><b>Explanation:</b> ${esc(q.explanation)}</div>` : "") +
(q.sourceRef ? `<div><b>Source:</b> \u201C${esc(q.sourceRef)}\u201D</div>` : "") +
(q.verification?.status === "warn" ? `<div class="warnnote">⚠ Reviewer note: ${esc(q.verification.note || "flagged")}</div>` : "") +
`</div>`;
}
const prompt = q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question;
return `<div class="q"><p class="qp"><b>${n}.</b> ${esc(prompt)} <span class="pts">(${q.points} pt${q.points === 1 ? "" : "s"})</span></p>${body}${key}</div>`;
}).join("");
return `${schoolHeadHtml(profile)}<h1>${esc(assignment.title)}</h1>
<div class="meta">${esc([assignment.subject, assignment.gradeLevel].filter(Boolean).join(" · "))} · ${totalPoints(qs)} points</div>
${teacher ? `<div class="teacherbar">Teacher version — answer key</div>` : `<div class="nameline">Name: ____________________________________&nbsp;&nbsp;&nbsp;Date: ________________</div>`}
${assignment.instructions ? `<p class="instr"><b>Instructions:</b> ${esc(assignment.instructions)}</p>` : ""}
${assignment.caseStudy ? `<div class="case">${esc(assignment.caseStudy).replace(/\n/g, "<br>")}</div>` : ""}
<hr>
${qHtml}`;
}
const PRINT_STYLES = `
body { font-family: Georgia, "Times New Roman", serif; color: #1a1a1a; max-width: 7.2in; margin: 0 auto; padding: 24px; font-size: 12.5pt; line-height: 1.5; }
h1 { font-size: 17pt; margin: 0 0 2px; }
.meta { color: #555; font-size: 10.5pt; margin-bottom: 4px; }
.teacherbar { color: #b8412f; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.06em; text-transform: uppercase; border: 1.5pt solid #b8412f; display: inline-block; padding: 2px 8px; margin: 4px 0 10px; }
.nameline { margin: 10px 0 14px; }
.instr { margin: 0 0 14px; }
.case { border: 1pt solid #999; padding: 12px 14px; margin: 0 0 16px; background: #fafafa; }
.q { margin: 0 0 16px; page-break-inside: avoid; }
.qp { margin: 0 0 4px; }
.pts { color: #666; font-size: 10pt; }
.opts { margin-left: 22px; }
.opts div { margin: 2px 0; }
.writelines { border-bottom: 1pt solid #888; height: 22px; margin: 8px 0 0 22px; }
.match td { vertical-align: top; padding-right: 36px; }
.match div { margin: 3px 0; }
.ans { color: #b8412f; font-weight: bold; }
.key { border-left: 2.5pt solid #b8412f; background: #fdf1ee; color: #7c2c1e; padding: 7px 11px; margin: 7px 0 0 22px; font-size: 11pt; }
.key div { margin: 2px 0; }
.warnnote { color: #8a6414; }
hr { border: 0; border-top: 1pt solid #ccc; margin: 14px 0; }
.schoolhead { display: flex; align-items: center; gap: 14px; border-bottom: 2pt solid #1a1a1a; padding-bottom: 9px; margin-bottom: 14px; }
.schoollogo { height: 46pt; max-width: 130pt; object-fit: contain; flex: none; }
.schoolname { font-size: 14pt; font-weight: bold; letter-spacing: 0.02em; }
.teachline { font-size: 10.5pt; color: #444; }
.pagebreak { page-break-after: always; break-after: page; }
@media print { body { padding: 0; } }
`;
// opts: { teacher } for a single version, or { packet: true } for the student
// version followed by the answer key in one document (page break between).
// { word: true } switches the page-break markup to the form Word understands.
export function buildHtml(assignment, opts) {
const profile = opts.profile || {};
const sep = opts.word
? `<br clear="all" style="page-break-before:always">`
: `<div class="pagebreak"></div>`;
const body = opts.packet
? buildBodyHtml(assignment, { teacher: false, profile }) + sep + buildBodyHtml(assignment, { teacher: true, profile })
: buildBodyHtml(assignment, { teacher: opts.teacher, profile });
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(assignment.title)}</title>
<style>${PRINT_STYLES}</style></head><body>
${body}
</body></html>`;
}
function download(filename, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);
}
function safeName(title, suffix, ext) {
const base = String(title || "assignment").replace(/[^\w\- ]+/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "assignment";
return `${base}-${suffix}.${ext}`;
}
function variantSuffix(opts) {
return opts.packet ? "packet" : opts.teacher ? "answer-key" : "student";
}
export function exportTxt(assignment, opts) {
const text = buildText(assignment, opts);
download(safeName(assignment.title, variantSuffix(opts), "txt"), new Blob([text], { type: "text/plain;charset=utf-8" }));
}
export function exportDoc(assignment, opts) {
const html = buildHtml(assignment, { ...opts, word: true });
download(
safeName(assignment.title, variantSuffix(opts), "doc"),
new Blob(["\ufeff" + html], { type: "application/msword" })
);
}
export async function exportClipboard(assignment, opts) {
await navigator.clipboard.writeText(buildText(assignment, opts));
}
export function exportPrint(assignment, opts) {
const html = buildHtml(assignment, opts);
const w = window.open("", "_blank");
if (!w) throw new Error("Your browser blocked the print window. Allow pop-ups for this site and try again.");
w.document.open();
w.document.write(html);
w.document.close();
w.focus();
setTimeout(() => w.print(), 400);
}
+67
View File
@@ -0,0 +1,67 @@
// lib/html-to-text.js — turn a fetched web page into clean, LLM-friendly text.
// Zero dependencies: pragmatic tag stripping + entity decoding.
const ENTITIES = {
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
mdash: "—", ndash: "", hellip: "…", rsquo: "'", lsquo: "'",
rdquo: '"', ldquo: '"', copy: "©", reg: "®", trade: "™",
deg: "°", frac12: "½", frac14: "¼", times: "×", divide: "÷",
eacute: "é", egrave: "è", agrave: "à", ccedil: "ç", uuml: "ü", ouml: "ö", auml: "ä",
};
function decodeEntities(s) {
return s
.replace(/&#x([0-9a-f]+);/gi, (_, h) => safeChar(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => safeChar(parseInt(d, 10)))
.replace(/&([a-z]+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m);
}
function safeChar(code) {
try { return String.fromCodePoint(code); } catch { return ""; }
}
export function extractTitle(html) {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!m) return "";
return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 200);
}
export function htmlToText(html) {
let s = String(html);
// Remove non-content blocks entirely
s = s.replace(/<!--[\s\S]*?-->/g, " ");
for (const tag of ["script", "style", "noscript", "svg", "iframe", "form", "nav", "footer", "header", "aside", "template", "button", "select"]) {
s = s.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}>`, "gi"), " ");
}
// Preserve structure: headings, paragraphs, list items, table cells, breaks
s = s.replace(/<\/(h[1-6])>/gi, "\n\n");
s = s.replace(/<(h[1-6])[^>]*>/gi, "\n\n## ");
s = s.replace(/<\/(p|div|section|article|blockquote|tr|table|ul|ol|figcaption)>/gi, "\n");
s = s.replace(/<li[^>]*>/gi, "\n- ");
s = s.replace(/<(td|th)[^>]*>/gi, " | ");
s = s.replace(/<br\s*\/?>/gi, "\n");
// Strip all remaining tags
s = s.replace(/<[^>]+>/g, " ");
s = decodeEntities(s);
// Normalize whitespace
s = s.replace(/\r/g, "");
s = s.replace(/[ \t]+/g, " ");
s = s.replace(/ ?\n ?/g, "\n");
s = s.replace(/\n{3,}/g, "\n\n");
// Drop very short junk lines (menus, single links) when the doc is large
const lines = s.split("\n").map((l) => l.trim());
const kept = [];
for (const line of lines) {
if (!line) { kept.push(""); continue; }
if (line.length < 3 && !/^[-#\d]/.test(line)) continue;
kept.push(line);
}
s = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
return s;
}
+90
View File
@@ -0,0 +1,90 @@
// lib/json-utils.js — tolerant JSON extraction for LLM responses.
// Local models often wrap JSON in prose or code fences, or leave trailing commas.
export function extractJson(text) {
if (text == null) throw new Error("Model returned an empty response.");
let s = String(text).trim();
// Strip code fences anywhere
s = s.replace(/```(?:json|javascript|js)?/gi, "```");
const fenced = s.match(/```([\s\S]*?)```/);
if (fenced && fenced[1].trim().match(/^[\[{]/)) s = fenced[1].trim();
// Slice from first brace/bracket to its matching end; try whichever starts first, first.
const candidates = [];
const firstObj = s.indexOf("{");
const firstArr = s.indexOf("[");
const objSlice = firstObj !== -1 ? s.slice(firstObj, s.lastIndexOf("}") + 1) : null;
const arrSlice = firstArr !== -1 ? s.slice(firstArr, s.lastIndexOf("]") + 1) : null;
if (firstArr !== -1 && (firstObj === -1 || firstArr < firstObj)) {
if (arrSlice) candidates.push(arrSlice);
if (objSlice) candidates.push(objSlice);
} else {
if (objSlice) candidates.push(objSlice);
if (arrSlice) candidates.push(arrSlice);
}
candidates.unshift(s);
let lastErr = null;
for (const c of candidates) {
if (!c) continue;
for (const attempt of [c, repair(c)]) {
try {
return JSON.parse(attempt);
} catch (e) {
lastErr = e;
}
}
}
const preview = s.slice(0, 300).replace(/\s+/g, " ");
throw new Error(
"Could not read the model's response as JSON. Try again, lower the temperature, or use a stronger model. Response began: " + preview
);
}
function repair(s) {
let out = s;
// Smart quotes -> straight quotes
out = out.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
// Remove trailing commas before } or ]
out = out.replace(/,\s*([}\]])/g, "$1");
// Remove JS-style comments
out = out.replace(/^\s*\/\/.*$/gm, "");
// Control characters inside strings break JSON.parse; replace bare newlines in strings
out = sanitizeNewlinesInStrings(out);
return out;
}
function sanitizeNewlinesInStrings(s) {
let result = "";
let inStr = false;
let escaped = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inStr) {
if (escaped) {
result += ch;
escaped = false;
continue;
}
if (ch === "\\") {
result += ch;
escaped = true;
continue;
}
if (ch === '"') {
inStr = false;
result += ch;
continue;
}
if (ch === "\n") { result += "\\n"; continue; }
if (ch === "\r") { continue; }
if (ch === "\t") { result += "\\t"; continue; }
result += ch;
} else {
if (ch === '"') inStr = true;
result += ch;
}
}
return result;
}
+195
View File
@@ -0,0 +1,195 @@
// lib/model-caps.js — sizes generation defaults to the selected model.
//
// "Auto" mode (generation.auto, on by default) asks the provider for the
// model's real limits and budgets maxTokens / maxSourceChars to fit:
// Ollama POST /api/show -> model_info.<arch>.context_length
// LM Studio GET /api/v0/models -> loaded_context_length / max_context_length
// Anthropic GET /v1/models/{id} -> max_input_tokens, max_tokens
// Google GET /v1beta/models/{id} -> inputTokenLimit, outputTokenLimit
// OpenAI (no limits API) -> pattern table below
// Lookups are cached in memory and every failure falls back to safe defaults,
// so generation never breaks because a limits lookup did.
const VERSION_HEADER = { "anthropic-version": "2023-06-01" };
// English prose averages ~4 chars/token; 3.5 leaves margin for dense text.
const CHARS_PER_TOKEN = 3.5;
const CAPS_TTL_OK = 5 * 60 * 1000;
const CAPS_TTL_FAIL = 30 * 1000;
const capsCache = new Map(); // "provider|baseUrl|model" -> { caps, at }
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]);
// Used when the provider can't be asked (server down, no key, unknown model).
const FALLBACK_CAPS = {
ollama: { contextTokens: 8192, maxOutputTokens: null },
lmstudio: { contextTokens: 8192, maxOutputTokens: null },
openai: { contextTokens: 128000, maxOutputTokens: 16384 },
anthropic: { contextTokens: 200000, maxOutputTokens: 8192 },
google: { contextTokens: 1000000, maxOutputTokens: 8192 },
};
function cleanBase(url, fallback) {
let b = (url || fallback || "").trim();
if (!b) return fallback;
return b.replace(/\/+$/, "");
}
function clamp(n, lo, hi) {
return Math.min(hi, Math.max(lo, n));
}
async function fetchJson(url, init = {}) {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
}
async function ollamaCaps(cfg) {
const base = cleanBase(cfg.baseUrl, "http://localhost:11434");
const data = await fetchJson(base + "/api/show", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: cfg.model }),
});
const info = data?.model_info || {};
const arch = info["general.architecture"];
let ctx = Number(arch ? info[`${arch}.context_length`] : 0) || 0;
if (!ctx) {
const key = Object.keys(info).find((k) => k.endsWith(".context_length"));
ctx = Number(key ? info[key] : 0) || 0;
}
// A num_ctx in the Modelfile is a deliberate (often memory-driven) cap — honor it.
const numCtx = Number((String(data?.parameters || "").match(/^num_ctx\s+(\d+)/m) || [])[1] || 0);
if (numCtx) ctx = ctx ? Math.min(ctx, numCtx) : numCtx;
if (!ctx) throw new Error("no context_length in /api/show response");
return { contextTokens: ctx, maxOutputTokens: null };
}
async function lmstudioCaps(cfg) {
const base = cleanBase(cfg.baseUrl, "http://localhost:1234");
const data = await fetchJson(base + "/api/v0/models");
const m = (data?.data || []).find((x) => x.id === cfg.model);
if (!m) throw new Error("model not in /api/v0/models");
// loaded_context_length is what the server actually honors; a not-yet-loaded
// model JIT-loads at its configured default, so stay conservative there.
const ctx =
Number(m.loaded_context_length) ||
Math.min(Number(m.max_context_length) || 8192, 8192);
return { contextTokens: ctx, maxOutputTokens: null };
}
async function anthropicCaps(cfg) {
if (!cfg.apiKey) throw new Error("no API key");
const data = await fetchJson(
`https://api.anthropic.com/v1/models/${encodeURIComponent(cfg.model)}`,
{ headers: { "x-api-key": cfg.apiKey, ...VERSION_HEADER } }
);
return {
contextTokens: Number(data?.max_input_tokens) || 200000,
maxOutputTokens: Number(data?.max_tokens) || 8192,
};
}
async function googleCaps(cfg) {
if (!cfg.apiKey) throw new Error("no API key");
const data = await fetchJson(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}?key=${encodeURIComponent(cfg.apiKey)}`
);
return {
contextTokens: Number(data?.inputTokenLimit) || 1000000,
maxOutputTokens: Number(data?.outputTokenLimit) || 8192,
};
}
// OpenAI's models API doesn't report limits, so match on the model name.
function openaiCaps(cfg) {
const m = String(cfg.model || "").toLowerCase();
const pick = (contextTokens, maxOutputTokens) => ({ contextTokens, maxOutputTokens });
if (/^o\d/.test(m)) return pick(200000, 100000);
if (m.includes("gpt-5")) return pick(272000, 128000);
if (m.includes("gpt-4.1")) return pick(1000000, 32768);
if (m.includes("gpt-4o") || m.includes("chatgpt-4o")) return pick(128000, 16384);
if (m.includes("gpt-4-turbo")) return pick(128000, 4096);
if (m.includes("gpt-4-32k")) return pick(32768, 8192);
if (m.includes("gpt-4")) return pick(8192, 4096);
if (m.includes("gpt-3.5")) return pick(16385, 4096);
return pick(128000, 16384);
}
// getModelCaps(settings) -> { contextTokens, maxOutputTokens|null, source }
// source: "live" (asked the provider) | "catalog" (pattern table) | "fallback"
export async function getModelCaps(settings) {
const provider = settings.provider;
const cfg = settings.providers?.[provider] || {};
const fallback = { ...(FALLBACK_CAPS[provider] || FALLBACK_CAPS.openai), source: "fallback" };
if (!cfg.model) return fallback;
// Key length is included so a cached "no key" fallback doesn't mask a freshly added key.
const key = `${provider}|${cfg.baseUrl || ""}|${cfg.model}|${(cfg.apiKey || "").length}`;
const hit = capsCache.get(key);
if (hit && Date.now() - hit.at < (hit.caps.source === "fallback" ? CAPS_TTL_FAIL : CAPS_TTL_OK)) {
return hit.caps;
}
let caps;
try {
if (provider === "ollama") caps = { ...(await ollamaCaps(cfg)), source: "live" };
else if (provider === "lmstudio") caps = { ...(await lmstudioCaps(cfg)), source: "live" };
else if (provider === "anthropic") caps = { ...(await anthropicCaps(cfg)), source: "live" };
else if (provider === "google") caps = { ...(await googleCaps(cfg)), source: "live" };
else if (provider === "openai") caps = { ...openaiCaps(cfg), source: "catalog" };
else caps = fallback;
} catch {
caps = fallback;
}
capsCache.set(key, { caps, at: Date.now() });
return caps;
}
function autoDefaults(caps, provider) {
const ctx = caps.contextTokens;
let maxTokens, overhead, sourceCap;
if (LOCAL_PROVIDERS.has(provider)) {
// Local models: spend at most a quarter of the window on the response and
// keep the source moderate — small models lose accuracy when drowned in
// text, and a bigger window costs RAM on the user's machine.
maxTokens = clamp(Math.floor(ctx / 4), 2000, 8000);
overhead = 3500; // system prompt + instructions + question list during verification
sourceCap = 32000;
} else {
// Cloud models: generous response budget (long assignments with rubrics)
// and room for much longer source material.
maxTokens = Math.min(caps.maxOutputTokens || 16000, 16000);
overhead = 3000;
sourceCap = 120000;
}
const sourceTokens = Math.max(1200, ctx - maxTokens - overhead);
const maxSourceChars = clamp(
Math.floor((sourceTokens * CHARS_PER_TOKEN) / 1000) * 1000,
4000,
sourceCap
);
return { maxTokens, maxSourceChars };
}
// resolveGeneration(settings) -> { auto, temperature, maxTokens, maxSourceChars, caps }
// In auto mode the limits are computed from the model's capabilities; in
// manual mode the user's stored values pass through. Never throws.
export async function resolveGeneration(settings) {
const gen = settings.generation || {};
const caps = await getModelCaps(settings);
const auto = gen.auto !== false;
const temperature = gen.temperature ?? 0.3;
if (!auto) {
return {
auto,
temperature,
maxTokens: gen.maxTokens ?? 8000,
maxSourceChars: gen.maxSourceChars ?? 24000,
caps,
};
}
return { auto, temperature, ...autoDefaults(caps, settings.provider), caps };
}
+278
View File
@@ -0,0 +1,278 @@
// lib/prompts.js — the accuracy core.
// Three-stage pipeline: ANALYZE the source -> GENERATE grounded questions -> VERIFY each one.
// Every prompt enforces one rule above all: nothing may be asked or answered
// that is not supported by the provided source material.
function truncateSource(source, maxChars) {
const s = String(source || "").trim();
if (s.length <= maxChars) return { text: s, truncated: false };
// Keep the beginning (usually the core content) plus the tail for conclusions.
const head = s.slice(0, Math.floor(maxChars * 0.8));
const tail = s.slice(-Math.floor(maxChars * 0.15));
return { text: head + "\n\n[... middle of source omitted for length ...]\n\n" + tail, truncated: true };
}
function gradeGuidance(gradeLevel) {
return `Write all question text, options, and answer-key material at a reading level appropriate for ${gradeLevel}. Use vocabulary and sentence length that students at this level can read independently. Do not simplify the underlying ideas below what the source supports — adjust the language, not the rigor.`;
}
// ---------------------------------------------------------------------------
// Grade-calibrated difficulty.
// Difficulty is always RELATIVE to the grade: "Easy" is the floor of that
// grade's ability band and "Hard" is its ceiling, and the whole band slides
// upward with grade — a Hard question for Grade 1 should be trivial for a
// 12th grader, while a Grade 12 Easy question should out-demand a Grade 1
// Hard one. This is what lets one assignment separate struggling, proficient,
// and advanced students at any grade.
// ---------------------------------------------------------------------------
function gradeIndex(gradeLevel) {
const g = String(gradeLevel || "").toLowerCase();
if (g.includes("kinder")) return 0;
const m = g.match(/(\d+)/);
if (m) return Math.min(12, Math.max(1, Number(m[1])));
if (g.includes("college")) return g.includes("adv") ? 14 : 13;
if (g.includes("adult")) return 13;
return 8;
}
const RIGOR_BANDS = [
{
max: 2,
expect: "single-step thinking about concrete facts stated plainly in the source",
floor: "recalling one clearly stated fact in simple words",
ceiling: "connecting two stated facts, putting events in order, or explaining why something happened when the source says so",
},
{
max: 5,
expect: "concrete reasoning with beginning inference",
floor: "recalling a specific fact, term, or definition from the source",
ceiling: "explaining cause and effect, comparing two ideas, or drawing a one-step inference that combines different parts of the source",
},
{
max: 8,
expect: "abstract concepts, multi-step reasoning, and real subject vocabulary",
floor: "accurate recall of specific facts and subject vocabulary",
ceiling: "multi-step inference, applying a concept from the source to a new example, or simple quantitative reasoning when the source includes numbers",
},
{
max: 12,
expect: "disciplinary thinking: analysis, evaluation, application, and technical vocabulary used correctly",
floor: "command of core concepts and technical vocabulary — not isolated trivia a younger student could guess",
ceiling: "synthesizing several parts of the source, applying concepts to unfamiliar scenarios, evaluating trade-offs, and multi-step quantitative problems whenever the source provides numbers, formulas, or processes",
},
{
max: 99,
expect: "rigorous, discipline-appropriate reasoning at college level",
floor: "precise command of the source's technical concepts and terminology",
ceiling: "critical evaluation, synthesis across the entire source, and demanding application problems — quantitative wherever the source supports it",
},
];
const DIFFICULTY_TARGETS = {
Easy: "Keep every question near the FLOOR of this band — but never below it.",
Medium: "Keep questions in the middle of this band: clearly beyond the floor, short of the ceiling.",
Hard: "Push every question to the CEILING of this band. No pure-recall items — every question should make even the strongest students think.",
Mixed: "Spread questions across the full band — roughly 1/3 near the floor, 1/3 mid-band, 1/3 at the ceiling — ordered easier to harder, so results separate struggling, proficient, and advanced students.",
};
function rigorGuidance(gradeLevel, difficulty) {
const i = gradeIndex(gradeLevel);
const band = RIGOR_BANDS.find((b) => i <= b.max);
const target = DIFFICULTY_TARGETS[difficulty] || DIFFICULTY_TARGETS.Mixed;
const belowCheck = i >= 3
? `\nIf a typical ${i >= 13 ? "high-school student" : "student two or three grades below"} could answer a question without studying this source, it is below the band — rewrite it harder.`
: "";
return `DIFFICULTY CALIBRATION — all difficulty is RELATIVE TO ${gradeLevel}:
Students at this level handle ${band.expect}.
- FLOOR of the band ("easy" at this grade) = ${band.floor}. Nearly every ${gradeLevel} student who studied the source should get floor questions right.
- CEILING of the band ("hard" at this grade) = ${band.ceiling}. Only the strongest ${gradeLevel} students should get ceiling questions right — while remaining fully answerable from the source alone.${belowCheck}
When the source contains numbers, formulas, processes, or worked examples, ceiling questions must make students USE them (compute, predict, apply, troubleshoot) — not merely recall them.
TARGET FOR THIS ASSIGNMENT (${difficulty || "Mixed"} difficulty): ${target}`;
}
const ACCURACY_RULES = `ACCURACY RULES (these override everything else):
1. Ground every question in the SOURCE MATERIAL only. Never use outside knowledge to add facts, dates, names, numbers, or claims that do not appear in the source.
2. Every answer in the answer key must be verifiably correct according to the source. If you are not certain the source supports an answer, do not write that question.
3. Each question must have exactly one defensible correct answer (essays and discussion prompts excepted). No trick questions, no "all of the above", no double negatives.
4. For every question, include a "sourceRef": a short quote or close paraphrase (under 25 words) of the exact place in the source that proves the correct answer.
5. If the source does not contain enough distinct material for the requested number of questions, write fewer questions rather than inventing content. Never pad with made-up facts.
6. Multiple-choice distractors must be plausible to a student who skimmed, but unambiguously wrong according to the source. Distractors must be about the same length and grammatical form as the correct answer. Vary the position of the correct answer across questions.
7. Fill-in-the-blank answers must be specific words or short phrases taken from the source, with exactly one sensible answer per blank. Mark each blank as ______ (six underscores).
8. True/false statements must be clearly and entirely true or entirely false per the source — never half-true.`;
const JSON_RULES = `OUTPUT FORMAT:
Respond with ONLY a single valid JSON object. No markdown, no code fences, no commentary before or after. Use double quotes for all strings. Escape internal quotes and newlines properly.`;
// ---------------------------------------------------------------------------
// Stage 1 — ANALYZE
// ---------------------------------------------------------------------------
export function analyzePrompt({ source, config, maxSourceChars }) {
const { text, truncated } = truncateSource(source, maxSourceChars);
const system = `You are an expert curriculum analyst. You read source material carefully and map exactly what it teaches, so that assessment questions can be grounded in it. You never invent content that is not in the source. ${JSON_RULES}`;
const user = `Analyze the following source material for a ${config.gradeLevel} ${config.subject} ${config.assignmentType.replace("_", " ")}.
${truncated ? "(Note: the middle of a long source was omitted; analyze what is present.)" : ""}
Return this JSON shape:
{
"summary": "2-3 sentence summary of what the source covers",
"keyConcepts": ["the 5-12 most important concepts/ideas, most important first"],
"keyFacts": ["8-20 specific, testable facts stated in the source (names, definitions, causes, numbers, sequences)"],
"vocabulary": ["important terms a ${config.gradeLevel} student should know from this source"],
"sufficientFor": <honest integer: how many distinct, non-overlapping questions this source can support>
}
SOURCE MATERIAL:
<<<
${text}
>>>`;
return { system, user };
}
// ---------------------------------------------------------------------------
// Stage 2 — GENERATE
// ---------------------------------------------------------------------------
const TYPE_SCHEMAS = `Question object shapes by "type":
- "multiple_choice": {"type":"multiple_choice","question":"...","options":["A text","B text","C text","D text"],"correctIndex":0,"explanation":"why the answer is correct AND why each distractor is wrong","sourceRef":"...","points":2}
- "true_false": {"type":"true_false","question":"statement to evaluate","correctAnswer":true,"explanation":"...","sourceRef":"...","points":1}
- "short_answer": {"type":"short_answer","question":"...","sampleAnswer":"a model answer in 1-3 sentences","keyPoints":["points a correct answer must include"],"explanation":"grading guidance","sourceRef":"...","points":3}
- "essay": {"type":"essay","question":"...","sampleResponse":"a strong model response (1-2 paragraphs or a detailed outline)","rubric":[{"criterion":"Thesis & focus","points":3,"description":"..."},{"criterion":"Use of evidence from the text","points":4,"description":"..."},{"criterion":"Organization & clarity","points":3,"description":"..."}],"sourceRef":"...","points":10}
- "fill_blank": {"type":"fill_blank","question":"Sentence with ______ for each blank.","answers":["answer for blank 1"],"explanation":"...","sourceRef":"...","points":2}
- "matching": {"type":"matching","question":"Match each item on the left with the correct item on the right.","pairs":[{"left":"term","right":"definition"}],"explanation":"...","sourceRef":"...","points":<number of pairs>}
- "discussion": {"type":"discussion","question":"open-ended discussion prompt","talkingPoints":["key themes a good discussion should surface"],"followUps":["1-3 follow-up questions to deepen the discussion"],"sampleResponse":"what a thoughtful contribution sounds like","sourceRef":"...","points":5}`;
export function generatePrompt({ source, analysis, config, maxSourceChars }) {
const { text, truncated } = truncateSource(source, maxSourceChars);
const isDiscussion = config.assignmentType === "discussion";
const isCaseStudy = config.assignmentType === "case_study";
let typeInstructions;
if (isDiscussion) {
typeInstructions = `All questions must be of type "discussion". Write open-ended prompts that invite multiple defensible positions, but anchor each prompt in specific content from the source (name the concept, event, or passage being discussed).`;
} else if (isCaseStudy) {
typeInstructions = `First write a "caseStudy": a realistic, self-contained scenario of 250-500 words (appropriate for ${config.gradeLevel}) that applies the concepts in the source to a concrete situation with named characters or organizations. The scenario must only use ideas, mechanisms, and facts supported by the source — the situation is invented, the underlying content is not.
Then write the questions ABOUT the scenario, using types "short_answer" and "essay" (and "discussion" if appropriate). Each question should require applying concepts from the source to the scenario. The sourceRef for each question should point to the source concept being applied.`;
} else {
const allowed = config.questionTypes && config.questionTypes.length ? config.questionTypes : ["multiple_choice", "true_false", "short_answer", "essay", "fill_blank", "matching"];
typeInstructions = `Use ONLY these question types: ${allowed.join(", ")}. Choose a sensible mix for a ${config.assignmentType} (e.g., quizzes lean on quick-check items; tests mix recall with deeper items; worksheets favor practice items like fill_blank and short_answer). Include at most one "matching" question and at most ${config.questionCount >= 10 ? 2 : 1} "essay" question(s) unless only those types are allowed.`;
}
const system = `You are a master teacher and assessment writer with 20 years of experience writing ${config.gradeLevel} ${config.subject} materials. Your assessments are known for being scrupulously accurate to the source text, unambiguous, and pitched perfectly to the grade level.
${ACCURACY_RULES}
${JSON_RULES}`;
const user = `Create a ${config.assignmentType.replace("_", " ")} for ${config.gradeLevel} ${config.subject}, based strictly on the source material below.
REQUIREMENTS:
- Number of questions: ${config.questionCount}${analysis?.sufficientFor ? ` (the source supports about ${analysis.sufficientFor}; if that is fewer, write fewer — never invent)` : ""}
- ${gradeGuidance(config.gradeLevel)}
- ${typeInstructions}
- ${config.includeExplanations ? "Include a clear, teacher-facing explanation for every question." : 'Keep "explanation" very brief (one sentence).'}
- ${config.includeRubrics ? "Include a point-based rubric for every essay question (criteria should sum to the question's points)." : "Rubrics optional."}
- Write concise student-facing instructions for the whole assignment (1-3 sentences, ${config.gradeLevel} reading level).
- Give the assignment a clear, specific title that names the actual topic (not the word "assignment").
${config.focusNote ? `- Teacher's focus request: ${config.focusNote}` : ""}
${rigorGuidance(config.gradeLevel, config.difficulty)}
${analysis ? `CONTENT MAP (from your earlier analysis — cover the most important concepts first, avoid asking two questions about the same fact):
${JSON.stringify({ keyConcepts: analysis.keyConcepts, keyFacts: analysis.keyFacts, vocabulary: analysis.vocabulary }, null, 1)}` : ""}
${TYPE_SCHEMAS}
Return this JSON shape:
{
"title": "...",
"instructions": "...",
${isCaseStudy ? '"caseStudy": "the 250-500 word scenario",' : ""}
"questions": [ ...question objects, in the order students should see them... ]
}
SOURCE MATERIAL:
<<<
${text}
>>>
${truncated ? "(Note: the middle of a long source was omitted. Only write questions about content you can actually see.)" : ""}`;
return { system, user };
}
// ---------------------------------------------------------------------------
// Stage 3 — VERIFY
// ---------------------------------------------------------------------------
export function verifyPrompt({ source, questions, config, maxSourceChars }) {
const { text } = truncateSource(source, maxSourceChars);
const compact = questions.map((q) => {
const base = { id: q.id, type: q.type, question: q.question, points: q.points };
if (q.type === "multiple_choice") return { ...base, options: q.options, correctIndex: q.correctIndex };
if (q.type === "true_false") return { ...base, correctAnswer: q.correctAnswer };
if (q.type === "short_answer") return { ...base, sampleAnswer: q.sampleAnswer, keyPoints: q.keyPoints };
if (q.type === "essay") return { ...base, sampleResponse: (q.sampleResponse || "").slice(0, 400) };
if (q.type === "fill_blank") return { ...base, answers: q.answers };
if (q.type === "matching") return { ...base, pairs: q.pairs };
if (q.type === "discussion") return { ...base, talkingPoints: q.talkingPoints };
return base;
});
const system = `You are a skeptical assessment editor reviewing a ${config.gradeLevel} ${config.subject} ${config.assignmentType.replace("_", " ")} before it goes to students. Your only loyalty is to accuracy. You check every question against the source material and you are not afraid to flag problems. ${JSON_RULES}`;
const user = `Review each question below against the SOURCE MATERIAL. For each one, check:
A. CORRECTNESS — Is the keyed answer actually correct according to the source (not according to general knowledge)?
B. GROUNDING — Is the question answerable from the source alone? Flag anything that relies on outside facts.
C. SINGLE ANSWER — Could a knowledgeable student defend a different answer? Are any multiple-choice distractors arguably also correct?
D. CLARITY — Is the wording unambiguous and readable at a ${config.gradeLevel} level?
E. MECHANICS — For fill_blank: does each blank have exactly one sensible answer? For matching: is every pairing unambiguous?
F. RIGOR — Is the question pitched at a ${config.gradeLevel} level? Flag any question so simple that a student several grades below could answer it without studying the source.
Be strict. A question only gets "pass" if it clears all six checks.
Return this JSON shape:
{
"results": [
{"id": "<question id>", "verdict": "pass" | "warn", "issue": "empty string if pass; otherwise a one-sentence description of the problem", "suggestedFix": "empty string, or a concrete suggested correction"}
]
}
Include every question id exactly once.
QUESTIONS:
${JSON.stringify(compact, null, 1)}
SOURCE MATERIAL:
<<<
${text}
>>>`;
return { system, user };
}
// ---------------------------------------------------------------------------
// Single-question regenerate / add
// ---------------------------------------------------------------------------
export function questionPrompt({ source, config, existingQuestions, type, note, replacing, maxSourceChars }) {
const { text } = truncateSource(source, maxSourceChars);
const existing = (existingQuestions || []).map((q) => q.question).filter(Boolean);
const system = `You are a master teacher writing one assessment question for a ${config.gradeLevel} ${config.subject} ${String(config.assignmentType || "quiz").replace("_", " ")}.
${ACCURACY_RULES}
${JSON_RULES}`;
const user = `Write exactly ONE new question of type "${type}", grounded strictly in the source material below.
- ${gradeGuidance(config.gradeLevel)}
- ${rigorGuidance(config.gradeLevel, config.difficulty).replace(/\n/g, "\n ")}
- It must not duplicate or closely overlap any of these existing questions:
${existing.length ? existing.map((q, i) => ` ${i + 1}. ${q}`).join("\n") : " (none)"}
${replacing ? `- It REPLACES this question, so cover a similar concept unless the teacher's note says otherwise: "${replacing.question}"` : ""}
${note ? `- Teacher's note (follow it): ${note}` : ""}
- Include a teacher-facing "explanation" and a "sourceRef" (short quote from the source proving the answer).
${TYPE_SCHEMAS}
Return ONLY the single question JSON object (not wrapped in an array or any other object).
SOURCE MATERIAL:
<<<
${text}
>>>`;
return { system, user };
}
+423
View File
@@ -0,0 +1,423 @@
// lib/providers.js — one interface, five backends.
// All calls are made server-side (no CORS issues, keys never touch the browser).
import { resolveGeneration } from "./model-caps";
const VERSION_HEADER = { "anthropic-version": "2023-06-01" };
function cleanBase(url, fallback) {
let b = (url || fallback || "").trim();
if (!b) return fallback;
return b.replace(/\/+$/, "");
}
async function readError(res) {
let detail = "";
try {
const j = await res.json();
detail = j?.error?.message || j?.error || j?.message || JSON.stringify(j).slice(0, 200);
} catch {
try { detail = (await res.text()).slice(0, 200); } catch {}
}
return detail;
}
function friendlyConnError(provider, base, err) {
const msg = String(err?.message || err);
const cause = String(err?.cause?.code || err?.cause?.message || "");
// Node's fetch aborts requests that go quiet too long; without this check a
// slow (but healthy) local model gets misreported as "not running".
if (/timeout/i.test(msg + " " + cause)) {
return new Error(
`${providerLabel(provider)} took too long to respond and the connection timed out. The model may still be working — for local models, try a smaller/faster model or wait and retry.`
);
}
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|aborted|network|terminated|socket/i.test(msg + " " + cause)) {
if (provider === "ollama")
return new Error(`Could not reach Ollama at ${base}. Make sure Ollama is running (open the Ollama app, or run \`ollama serve\`).`);
if (provider === "lmstudio")
return new Error(`Could not reach LM Studio at ${base}. In LM Studio, open the Developer tab and start the local server.`);
return new Error(`Could not reach the ${provider} API. Check your internet connection. (${msg})`);
}
return err;
}
// ----------------------------------------------------------------------------
// chat(settings, opts) -> string
// opts: { system, user, temperature, maxTokens, expectJson }
// ----------------------------------------------------------------------------
export async function chat(settings, opts) {
const provider = settings.provider;
const cfg = settings.providers?.[provider] || {};
const { system = "", user = "", expectJson = false, signal } = opts;
if (!cfg.model) {
throw new Error(`No model selected for ${providerLabel(provider)}. Open Settings, pick a model, and save.`);
}
// Per-model defaults: auto mode sizes these to the model's real limits.
const resolved = await resolveGeneration(settings);
const temperature = opts.temperature ?? resolved.temperature;
let maxTokens = opts.maxTokens ?? resolved.maxTokens;
if (resolved.caps.maxOutputTokens) maxTokens = Math.min(maxTokens, resolved.caps.maxOutputTokens);
switch (provider) {
case "ollama":
return ollamaChat(cfg, {
system, user, temperature, maxTokens, expectJson, signal,
// Only size num_ctx when we actually know the model's window.
contextTokens: resolved.caps.source === "live" ? resolved.caps.contextTokens : 0,
});
case "lmstudio":
return openaiCompatChat("lmstudio", cleanBase(cfg.baseUrl, "http://localhost:1234") + "/v1", null, cfg.model, { system, user, temperature, maxTokens, expectJson, signal });
case "openai":
requireKey(cfg, "OpenAI");
return openaiCompatChat("openai", "https://api.openai.com/v1", cfg.apiKey, cfg.model, { system, user, temperature, maxTokens, expectJson, signal });
case "anthropic":
requireKey(cfg, "Anthropic");
return anthropicChat(cfg, { system, user, temperature, maxTokens, signal });
case "google":
requireKey(cfg, "Google");
return googleChat(cfg, { system, user, temperature, maxTokens, expectJson, signal });
default:
throw new Error(`Unknown provider: ${provider}`);
}
}
function requireKey(cfg, name) {
if (!cfg.apiKey) throw new Error(`No API key set for ${name}. Add it on the Settings page.`);
}
// ---------- streaming helpers ----------
// All backends stream and accumulate server-side. Non-streaming requests sit
// silent until the full response is ready, and Node's fetch kills any request
// whose headers take >5 minutes — which long local generations routinely do.
// Streaming returns headers instantly and each chunk resets the idle timer.
async function* streamLines(res) {
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (line) yield line;
}
}
const last = (buf + decoder.decode()).trim();
if (last) yield last;
}
// Parse server-sent events, invoking onEvent for each JSON data payload.
async function readSse(res, onEvent) {
for await (const line of streamLines(res)) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
let obj;
try { obj = JSON.parse(payload); } catch { continue; }
onEvent(obj);
}
}
export function providerLabel(p) {
return { ollama: "Ollama", lmstudio: "LM Studio", openai: "OpenAI", anthropic: "Anthropic", google: "Google AI" }[p] || p;
}
// ---------- Ollama ----------
async function ollamaChat(cfg, { system, user, temperature, maxTokens, expectJson, signal, contextTokens }) {
const base = cleanBase(cfg.baseUrl, "http://localhost:11434");
const options = { temperature, num_predict: maxTokens };
// Ollama defaults num_ctx to ~4k and silently truncates longer prompts, so
// size the window to this request, bounded by the model's real maximum.
// chars/3 over-estimates tokens on purpose; the margin covers chat template.
if (contextTokens) {
const needed = Math.ceil((system.length + user.length) / 3) + maxTokens + 512;
options.num_ctx = Math.min(contextTokens, Math.max(4096, Math.ceil(needed / 1024) * 1024));
}
let res;
try {
res = await fetch(base + "/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
model: cfg.model,
stream: true,
messages: [
...(system ? [{ role: "system", content: system }] : []),
{ role: "user", content: user },
],
options,
...(expectJson ? { format: "json" } : {}),
}),
});
} catch (e) {
throw friendlyConnError("ollama", base, e);
}
if (!res.ok) throw new Error(`Ollama error (${res.status}): ${await readError(res)}`);
// NDJSON stream: one JSON object per line.
let out = "";
try {
for await (const line of streamLines(res)) {
let obj;
try { obj = JSON.parse(line); } catch { continue; }
if (obj?.error) throw new Error(`Ollama error: ${obj.error}`);
if (obj?.message?.content) out += obj.message.content;
}
} catch (e) {
if (/^Ollama error:/.test(String(e?.message))) throw e;
throw friendlyConnError("ollama", base, e);
}
return out;
}
// ---------- OpenAI-compatible (OpenAI + LM Studio) ----------
async function openaiCompatChat(provider, base, apiKey, model, { system, user, temperature, maxTokens, expectJson, signal }) {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = "Bearer " + apiKey;
const body = {
model,
stream: true,
temperature,
max_tokens: maxTokens,
messages: [
...(system ? [{ role: "system", content: system }] : []),
{ role: "user", content: user },
],
};
if (expectJson && provider === "openai") body.response_format = { type: "json_object" };
const send = () =>
fetch(base + "/chat/completions", { method: "POST", headers, signal, body: JSON.stringify(body) });
let res;
try {
res = await send();
} catch (e) {
throw friendlyConnError(provider, base, e);
}
// Some models reject response_format, temperature, or streaming; retry once without.
if (!res.ok) {
const detail = await readError(res);
let changed = false;
if (/response_format|temperature|unsupported|param/i.test(detail) && (body.response_format || body.temperature !== undefined)) {
delete body.response_format;
delete body.temperature;
body.max_completion_tokens = body.max_tokens;
delete body.max_tokens;
changed = true;
}
if (/stream/i.test(detail)) {
body.stream = false;
changed = true;
}
if (!changed) throw new Error(`${providerLabel(provider)} error (${res.status}): ${detail}`);
try {
res = await send();
} catch (e) {
throw friendlyConnError(provider, base, e);
}
if (!res.ok) throw new Error(`${providerLabel(provider)} error (${res.status}): ${await readError(res)}`);
}
if (!body.stream) {
const data = await res.json();
return data?.choices?.[0]?.message?.content ?? "";
}
let out = "";
try {
await readSse(res, (obj) => {
const delta = obj?.choices?.[0]?.delta;
if (delta?.content) out += delta.content;
});
} catch (e) {
throw friendlyConnError(provider, base, e);
}
return out;
}
// ---------- Anthropic ----------
async function anthropicChat(cfg, { system, user, temperature, maxTokens, signal }) {
const body = {
model: cfg.model,
max_tokens: maxTokens,
temperature,
stream: true,
...(system ? { system } : {}),
messages: [{ role: "user", content: user }],
};
const send = () =>
fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": cfg.apiKey, ...VERSION_HEADER },
signal,
body: JSON.stringify(body),
});
let res;
try {
res = await send();
} catch (e) {
throw friendlyConnError("anthropic", "api.anthropic.com", e);
}
// Newer Anthropic models (Opus 4.7+, Fable) reject sampling parameters;
// retry once without temperature.
if (!res.ok && res.status === 400 && body.temperature !== undefined) {
const detail = await readError(res);
if (/temperature|top_p|top_k|sampling/i.test(detail)) {
delete body.temperature;
try {
res = await send();
} catch (e) {
throw friendlyConnError("anthropic", "api.anthropic.com", e);
}
} else {
throw new Error(`Anthropic error (400): ${detail}`);
}
}
if (!res.ok) throw new Error(`Anthropic error (${res.status}): ${await readError(res)}`);
let out = "";
try {
await readSse(res, (obj) => {
if (obj?.type === "content_block_delta" && obj.delta?.type === "text_delta") out += obj.delta.text;
if (obj?.type === "error") throw new Error(`Anthropic error: ${obj.error?.message || JSON.stringify(obj.error)}`);
});
} catch (e) {
if (/^Anthropic error:/.test(String(e?.message))) throw e;
throw friendlyConnError("anthropic", "api.anthropic.com", e);
}
return out;
}
// ---------- Google ----------
async function googleChat(cfg, { system, user, temperature, maxTokens, expectJson, signal }) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(cfg.apiKey)}`;
let res;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
contents: [{ role: "user", parts: [{ text: user }] }],
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
...(expectJson ? { responseMimeType: "application/json" } : {}),
},
}),
});
} catch (e) {
throw friendlyConnError("google", "generativelanguage.googleapis.com", e);
}
if (!res.ok) throw new Error(`Google AI error (${res.status}): ${await readError(res)}`);
let out = "";
try {
await readSse(res, (obj) => {
if (obj?.error) throw new Error(`Google AI error: ${obj.error?.message || JSON.stringify(obj.error)}`);
const parts = obj?.candidates?.[0]?.content?.parts || [];
out += parts.map((p) => p.text || "").join("");
});
} catch (e) {
if (/^Google AI error:/.test(String(e?.message))) throw e;
throw friendlyConnError("google", "generativelanguage.googleapis.com", e);
}
return out;
}
// ----------------------------------------------------------------------------
// listModels(settings, provider) -> string[]
// ----------------------------------------------------------------------------
export async function listModels(settings, provider) {
const cfg = settings.providers?.[provider] || {};
const t = AbortSignal.timeout(15000);
try {
if (provider === "ollama") {
const base = cleanBase(cfg.baseUrl, "http://localhost:11434");
const res = await fetch(base + "/api/tags", { signal: t }).catch((e) => { throw friendlyConnError("ollama", base, e); });
if (!res.ok) throw new Error(`Ollama error (${res.status}): ${await readError(res)}`);
const data = await res.json();
const models = (data?.models || []).map((m) => m.name).sort();
if (!models.length) throw new Error("Ollama is running but has no models installed. Run e.g. `ollama pull llama3.1:8b` first.");
return models;
}
if (provider === "lmstudio") {
const base = cleanBase(cfg.baseUrl, "http://localhost:1234");
const res = await fetch(base + "/v1/models", { signal: t }).catch((e) => { throw friendlyConnError("lmstudio", base, e); });
if (!res.ok) throw new Error(`LM Studio error (${res.status}): ${await readError(res)}`);
const data = await res.json();
const models = (data?.data || []).map((m) => m.id).sort();
if (!models.length) throw new Error("LM Studio server is running but no model is loaded. Load a model in LM Studio first.");
return models;
}
if (provider === "openai") {
requireKey(cfg, "OpenAI");
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: "Bearer " + cfg.apiKey }, signal: t,
}).catch((e) => { throw friendlyConnError("openai", "api.openai.com", e); });
if (!res.ok) throw new Error(`OpenAI error (${res.status}): ${await readError(res)}`);
const data = await res.json();
return (data?.data || [])
.map((m) => m.id)
.filter((id) => /^(gpt-|o\d|chatgpt-)/.test(id) && !/audio|realtime|tts|whisper|image|embed|moderation|transcribe|search/.test(id))
.sort();
}
if (provider === "anthropic") {
requireKey(cfg, "Anthropic");
const res = await fetch("https://api.anthropic.com/v1/models?limit=100", {
headers: { "x-api-key": cfg.apiKey, ...VERSION_HEADER }, signal: t,
}).catch((e) => { throw friendlyConnError("anthropic", "api.anthropic.com", e); });
if (!res.ok) throw new Error(`Anthropic error (${res.status}): ${await readError(res)}`);
const data = await res.json();
return (data?.data || []).map((m) => m.id).sort();
}
if (provider === "google") {
requireKey(cfg, "Google");
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?pageSize=100&key=${encodeURIComponent(cfg.apiKey)}`,
{ signal: t }
).catch((e) => { throw friendlyConnError("google", "generativelanguage.googleapis.com", e); });
if (!res.ok) throw new Error(`Google AI error (${res.status}): ${await readError(res)}`);
const data = await res.json();
return (data?.models || [])
.filter((m) => (m.supportedGenerationMethods || []).includes("generateContent"))
.map((m) => String(m.name || "").replace(/^models\//, ""))
.sort();
}
throw new Error("Unknown provider: " + provider);
} catch (e) {
if (e?.name === "TimeoutError") throw new Error(`Timed out reaching ${providerLabel(provider)}.`);
throw e;
}
}
// ----------------------------------------------------------------------------
// testConnection(settings, provider) -> { ok, message }
// ----------------------------------------------------------------------------
export async function testConnection(settings, provider) {
const probe = { ...settings, provider };
const reply = await chat(probe, {
system: "You are a connection test. Reply with exactly: OK",
user: "Reply with exactly: OK",
temperature: 0,
// Reasoning models (o-series, gpt-5, gemini-2.5, Claude thinking) spend tokens
// on hidden reasoning before any visible text, so keep a generous budget here
// or the reply comes back empty even though the connection is fine.
maxTokens: 2048,
});
if (!reply || !reply.trim()) {
throw new Error(
"The connection worked but the model returned no text. If this is a reasoning model, it may have used its whole token budget on internal reasoning — try a non-reasoning model, or this is usually safe to ignore."
);
}
return { ok: true, message: `Connected. Model replied: "${reply.trim().slice(0, 40)}"` };
}
+175
View File
@@ -0,0 +1,175 @@
// lib/schema.js — shared constants + normalization that repairs whatever the model returns
// into a guaranteed-valid assignment structure. Runs on both server and client.
export const ASSIGNMENT_TYPES = [
{ id: "quiz", label: "Quiz", hint: "Short check for understanding" },
{ id: "test", label: "Test", hint: "Full exam with mixed sections" },
{ id: "worksheet", label: "Worksheet", hint: "Guided practice to work through" },
{ id: "discussion", label: "Discussion questions", hint: "Open prompts with facilitation notes" },
{ id: "case_study", label: "Case study", hint: "A scenario plus analysis questions" },
];
export const QUESTION_TYPES = [
{ id: "multiple_choice", label: "Multiple choice" },
{ id: "true_false", label: "True / False" },
{ id: "short_answer", label: "Short answer" },
{ id: "essay", label: "Essay" },
{ id: "fill_blank", label: "Fill in the blank" },
{ id: "matching", label: "Matching" },
];
export const GRADE_LEVELS = [
"Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4", "Grade 5",
"Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12",
"College — introductory", "College — advanced", "Adult education",
];
export const DIFFICULTIES = ["Easy", "Medium", "Hard", "Mixed"];
export const DEFAULT_POINTS = {
multiple_choice: 2,
true_false: 1,
short_answer: 3,
essay: 10,
fill_blank: 2,
matching: 4,
discussion: 5,
};
export function questionTypeLabel(type) {
if (type === "discussion") return "Discussion prompt";
return QUESTION_TYPES.find((t) => t.id === type)?.label || type;
}
export function newId() {
return "q_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function str(v, fallback = "") {
if (v == null) return fallback;
return String(v).trim() || fallback;
}
function num(v, fallback) {
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function arr(v) {
return Array.isArray(v) ? v : [];
}
// Normalize one question object from the model (or the editor) into a valid shape.
// Returns null if the question is hopeless and should be dropped.
export function normalizeQuestion(raw) {
if (!raw || typeof raw !== "object") return null;
let type = str(raw.type).toLowerCase().replace(/[\s-]+/g, "_");
const aliases = {
multiplechoice: "multiple_choice", mcq: "multiple_choice", multiple_choice_question: "multiple_choice",
truefalse: "true_false", tf: "true_false",
shortanswer: "short_answer", short_response: "short_answer",
fillblank: "fill_blank", fill_in_the_blank: "fill_blank", fitb: "fill_blank", cloze: "fill_blank",
match: "matching", open_ended: "discussion", discussion_prompt: "discussion",
};
type = aliases[type] || type;
const known = ["multiple_choice", "true_false", "short_answer", "essay", "fill_blank", "matching", "discussion"];
if (!known.includes(type)) return null;
const q = {
id: str(raw.id) || newId(),
type,
question: str(raw.question || raw.prompt || raw.text),
points: num(raw.points, DEFAULT_POINTS[type]),
explanation: str(raw.explanation),
sourceRef: str(raw.sourceRef || raw.source_ref || raw.source),
verification: raw.verification && typeof raw.verification === "object"
? { status: raw.verification.status === "warn" ? "warn" : raw.verification.status === "pass" ? "pass" : "unchecked", note: str(raw.verification.note) }
: { status: "unchecked", note: "" },
};
if (!q.question) return null;
if (type === "multiple_choice") {
let options = arr(raw.options || raw.choices).map((o) => str(typeof o === "object" ? o?.text : o)).filter(Boolean);
if (options.length < 2) return null;
options = options.slice(0, 6);
let ci = raw.correctIndex ?? raw.correct_index;
if (ci == null && raw.correctAnswer != null) {
// Model may give the answer as a letter ("B") or the option text.
const ca = str(raw.correctAnswer);
const letter = ca.match(/^[A-F]$/i);
if (letter) ci = letter[0].toUpperCase().charCodeAt(0) - 65;
else {
const idx = options.findIndex((o) => o.toLowerCase() === ca.toLowerCase());
ci = idx >= 0 ? idx : 0;
}
}
ci = Math.min(Math.max(num(ci, 0), 0), options.length - 1);
q.options = options;
q.correctIndex = ci;
} else if (type === "true_false") {
let ans = raw.correctAnswer ?? raw.answer;
if (typeof ans === "string") ans = /^(t|true|yes)/i.test(ans.trim());
q.correctAnswer = Boolean(ans);
} else if (type === "short_answer") {
q.sampleAnswer = str(raw.sampleAnswer || raw.sample_answer || raw.answer || raw.correctAnswer);
q.keyPoints = arr(raw.keyPoints || raw.key_points).map((k) => str(k)).filter(Boolean);
} else if (type === "essay") {
q.sampleResponse = str(raw.sampleResponse || raw.sample_response || raw.sampleAnswer || raw.answer);
q.rubric = arr(raw.rubric).map((r) => {
if (typeof r === "string") return { criterion: str(r), points: 0, description: "" };
return { criterion: str(r?.criterion || r?.name), points: num(r?.points, 0), description: str(r?.description) };
}).filter((r) => r.criterion);
} else if (type === "fill_blank") {
// Ensure the question text actually contains blanks
let text = q.question.replace(/_{2,}/g, "______");
let answers = arr(raw.answers || raw.blanks).map((a) => str(typeof a === "object" ? a?.answer : a)).filter(Boolean);
if (!answers.length && raw.answer) answers = [str(raw.answer)];
if (!answers.length) return null;
const blanks = (text.match(/______/g) || []).length;
if (blanks === 0) return null;
q.question = text;
q.answers = answers.slice(0, blanks).concat(Array(Math.max(0, blanks - answers.length)).fill("")).map((a) => a || "(answer missing — edit me)");
} else if (type === "matching") {
const pairs = arr(raw.pairs || raw.items).map((p) => ({ left: str(p?.left || p?.term), right: str(p?.right || p?.definition || p?.match) }))
.filter((p) => p.left && p.right);
if (pairs.length < 2) return null;
q.pairs = pairs.slice(0, 10);
q.points = num(raw.points, q.pairs.length);
} else if (type === "discussion") {
q.talkingPoints = arr(raw.talkingPoints || raw.talking_points || raw.keyPoints).map((k) => str(k)).filter(Boolean);
q.followUps = arr(raw.followUps || raw.follow_ups).map((k) => str(k)).filter(Boolean);
q.sampleResponse = str(raw.sampleResponse || raw.sample_response);
}
return q;
}
export function normalizeAssignment(raw, config) {
const questions = arr(raw?.questions).map(normalizeQuestion).filter(Boolean);
return {
title: str(raw?.title, "Untitled assignment"),
instructions: str(raw?.instructions),
caseStudy: str(raw?.caseStudy || raw?.case_study || raw?.scenario),
assignmentType: config.assignmentType,
gradeLevel: config.gradeLevel,
subject: config.subject,
difficulty: config.difficulty,
questions,
};
}
export function totalPoints(questions) {
return (questions || []).reduce((s, q) => s + (Number(q.points) || 0), 0);
}
// Create a blank question of a given type for the "Add question" menu.
export function blankQuestion(type) {
const base = { id: newId(), type, question: "", points: DEFAULT_POINTS[type] || 2, explanation: "", sourceRef: "", verification: { status: "unchecked", note: "" } };
if (type === "multiple_choice") return { ...base, options: ["", "", "", ""], correctIndex: 0 };
if (type === "true_false") return { ...base, correctAnswer: true };
if (type === "short_answer") return { ...base, sampleAnswer: "", keyPoints: [] };
if (type === "essay") return { ...base, sampleResponse: "", rubric: [] };
if (type === "fill_blank") return { ...base, question: "______", answers: [""] };
if (type === "matching") return { ...base, pairs: [{ left: "", right: "" }, { left: "", right: "" }], points: 2 };
if (type === "discussion") return { ...base, talkingPoints: [], followUps: [], sampleResponse: "" };
return base;
}
+144
View File
@@ -0,0 +1,144 @@
// lib/store.js — single-file local database (data/db.json) with atomic writes.
// Portable: copy data/db.json to move your whole library + settings anywhere.
import fs from "fs";
import path from "path";
const DATA_DIR = path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, "db.json");
export const DEFAULT_SETTINGS = {
// Shown in the header of printed/exported assignments.
profile: {
teacherName: "",
className: "",
schoolName: "",
logo: "", // small data-URL image (downscaled client-side before saving)
},
provider: "ollama",
providers: {
// Env overrides let the Docker image default to the host machine's
// Ollama / LM Studio (host.docker.internal) without touching the UI.
ollama: { baseUrl: process.env.OLLAMA_BASE_URL || "http://localhost:11434", model: "" },
lmstudio: { baseUrl: process.env.LMSTUDIO_BASE_URL || "http://localhost:1234", model: "" },
openai: { apiKey: "", model: "gpt-4o-mini" },
anthropic: { apiKey: "", model: "claude-sonnet-4-6" },
google: { apiKey: "", model: "gemini-2.0-flash" },
},
generation: {
auto: true, // size maxTokens/maxSourceChars to the selected model (lib/model-caps.js)
temperature: 0.3,
maxTokens: 8000,
maxSourceChars: 24000,
verification: true,
},
};
function emptyDb() {
return { assignments: [], settings: structuredClone(DEFAULT_SETTINGS) };
}
function readDb() {
try {
if (!fs.existsSync(DB_PATH)) return emptyDb();
const raw = fs.readFileSync(DB_PATH, "utf8");
const db = JSON.parse(raw);
if (!Array.isArray(db.assignments)) db.assignments = [];
db.settings = mergeSettings(db.settings);
return db;
} catch {
// Corrupt file: keep a backup, start fresh rather than crash.
try { fs.copyFileSync(DB_PATH, DB_PATH + ".corrupt-" + Date.now()); } catch {}
return emptyDb();
}
}
function writeDb(db) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const tmp = DB_PATH + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(db, null, 2), "utf8");
fs.renameSync(tmp, DB_PATH);
}
export function mergeSettings(saved) {
const base = structuredClone(DEFAULT_SETTINGS);
if (!saved || typeof saved !== "object") return base;
const out = { ...base, ...saved };
out.providers = { ...base.providers };
for (const key of Object.keys(base.providers)) {
out.providers[key] = { ...base.providers[key], ...(saved.providers?.[key] || {}) };
}
out.generation = { ...base.generation, ...(saved.generation || {}) };
out.profile = { ...base.profile, ...(saved.profile || {}) };
return out;
}
// ---------- Settings ----------
export function getSettings() {
return readDb().settings;
}
export function saveSettings(settings) {
const db = readDb();
db.settings = mergeSettings(settings);
writeDb(db);
return db.settings;
}
// ---------- Assignments ----------
export function listAssignments() {
const db = readDb();
return db.assignments
.map((a) => ({
id: a.id,
title: a.title || "Untitled assignment",
assignmentType: a.assignmentType,
gradeLevel: a.gradeLevel,
subject: a.subject,
questionCount: (a.questions || []).length,
totalPoints: (a.questions || []).reduce((s, q) => s + (Number(q.points) || 0), 0),
createdAt: a.createdAt,
updatedAt: a.updatedAt,
}))
.sort((x, y) => String(y.updatedAt).localeCompare(String(x.updatedAt)));
}
export function getAssignment(id) {
return readDb().assignments.find((a) => a.id === id) || null;
}
export function createAssignment(assignment) {
const db = readDb();
const now = new Date().toISOString();
const record = {
...assignment,
id: assignment.id || "a_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7),
createdAt: now,
updatedAt: now,
};
db.assignments.push(record);
writeDb(db);
return record;
}
export function updateAssignment(id, patch) {
const db = readDb();
const idx = db.assignments.findIndex((a) => a.id === id);
if (idx === -1) return null;
db.assignments[idx] = {
...db.assignments[idx],
...patch,
id,
createdAt: db.assignments[idx].createdAt,
updatedAt: new Date().toISOString(),
};
writeDb(db);
return db.assignments[idx];
}
export function deleteAssignment(id) {
const db = readDb();
const before = db.assignments.length;
db.assignments = db.assignments.filter((a) => a.id !== id);
writeDb(db);
return db.assignments.length < before;
}