commit 5a51a0f112a31873a46e6807d3714357f0e2c3f5 Author: bizzle Date: Sun Jun 21 19:58:36 2026 -0400 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54c6cd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +data +docker +.git +.gitignore +.DS_Store +*.png +*.pdf +*.log +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db2b97b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +data/db.json +.DS_Store +*.pdf +*.png +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..681e1fd --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# ✎ Mr. Drew's Assignment Creator + +A local, private, accuracy-first generator for **quizzes, tests, worksheets, discussion questions, and case studies** — for any grade level from kindergarten to college. + +Everything runs in a single Docker container on your own machine. With a local AI (Ollama or LM Studio), nothing you type ever leaves your computer. + +--- + +## Why it's accurate + +Most AI assignment tools fire off one giant "make me a quiz" request and hope for the best. This app runs a **three-stage pipeline** instead: + +1. **Analyze** — the AI reads your source material and maps its key concepts, facts, and vocabulary. +2. **Generate** — questions are written *strictly from your source*. The AI is forbidden from adding outside facts, and every question carries a `Source:` quote proving where its answer comes from. +3. **Verify** — a second, skeptical AI pass re-checks every question and answer against the source. Each is stamped **✓ Verified**, or flagged **⚠ Check this** with a note telling you exactly what to look at. + +You stay in control: every question can be edited, regenerated with a steering note ("make it harder", "focus on causes"), or replaced — and you can re-run the accuracy check any time. + +--- + +## What you need + +- **Docker** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) on macOS/Windows, or Docker Engine on Linux. That's the only thing you install. +- **One AI provider** (pick any one): + - **Ollama** (free, private, local) — + - **LM Studio** (free, private, local) — + - An **Anthropic**, **OpenAI**, or **Google AI** API key (cloud, pay-per-use) — paste it on the Settings page, no networking setup needed. + +--- + +## Quick start + +### macOS / Windows (Docker Desktop) + +```sh +git clone https://git.bizzle.lol/bizzle/mr-drews-assignment-creator.git +cd mr-drews-assignment-creator/docker +docker compose up -d +``` + +Open ****. Done. + +The container automatically reaches the Ollama / LM Studio running on your machine (via `host.docker.internal`). Just make sure one of them is running: +- **Ollama** — have the Ollama app open (or run `ollama serve`). +- **LM Studio** — open the Developer tab and start the local server. + +### Linux + +```sh +git clone https://git.bizzle.lol/bizzle/mr-drews-assignment-creator.git +cd mr-drews-assignment-creator/docker +docker compose -f docker-compose.linux.yml up -d +``` + +Open ****. This variant uses host networking, so the container sees Ollama / LM Studio on plain `localhost` — no extra setup, even if Ollama is bound to `127.0.0.1` (its default). + +> The first `docker compose up` builds the image from source — expect it to take a couple of minutes. Subsequent starts are instant. + +--- + +## First run: pick your AI + +1. Open and go to **Settings**. +2. Choose a provider: + - **Ollama / LM Studio** — confirm the server address, then click **Test connection**. + - **Anthropic / OpenAI / Google** — paste your API key. +3. Save. Head back to the home page and generate your first assignment. + +--- + +## Where is my data? + +Everything — assignments, settings, API keys, your school logo — lives in a single JSON file inside the `assignment-data` Docker volume. It survives restarts, rebuilds, and image upgrades. + +Back it up any time: + +```sh +cd docker +docker compose cp assignment-creator:/app/data/db.json ./db-backup.json +``` + +--- + +## Ollama / LM Studio on a *different* machine + +Common setup: the app runs in Docker on a server or NAS, while Ollama runs on your desktop with the GPU. Two steps: + +1. **On the machine running the LLM**, allow network connections: + - **Ollama** — set `OLLAMA_HOST=0.0.0.0` (Ollama app → Settings → "Expose Ollama to the network", or the env var) and restart Ollama. + - **LM Studio** — Developer tab → server settings → enable **"Serve on Local Network"**. +2. **In the app**, open Settings, select Ollama or LM Studio, and enter that machine's address in **"Server address (base URL)"** — e.g. `http://192.168.1.50:11434`. Click **Test connection**, then Save. + +The saved address always wins over the compose defaults. You can also pre-set defaults for fresh installs via the `OLLAMA_BASE_URL` / `LMSTUDIO_BASE_URL` environment variables in `docker/docker-compose.yml`. + +--- + +## Updating + +```sh +cd docker +docker compose up -d --build +``` + +Your data volume is untouched by rebuilds. + +--- + +## Troubleshooting + +- **"Could not reach Ollama" on macOS/Windows** — confirm Ollama is running on your machine (`ollama list` in a terminal). The default compose file already points the app at your machine, not at the container. +- **"Could not reach Ollama" on Linux with the default compose file** — use `docker-compose.linux.yml` instead, or set `OLLAMA_HOST=0.0.0.0` so Ollama accepts connections from containers. +- **Port 3000 already in use** — change the first number in `ports:` in `docker/docker-compose.yml` (e.g. `"8080:3000"`) and open . +- **Stop the app** — `cd docker && docker compose down` (your data volume is kept). diff --git a/app/api/assignments/[id]/route.js b/app/api/assignments/[id]/route.js new file mode 100644 index 0000000..1fdf6a4 --- /dev/null +++ b/app/api/assignments/[id]/route.js @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getAssignment, updateAssignment, deleteAssignment } from "@/lib/store"; + +export const dynamic = "force-dynamic"; + +export async function GET(request, { params }) { + const a = getAssignment(params.id); + if (!a) return NextResponse.json({ error: "Assignment not found." }, { status: 404 }); + return NextResponse.json(a); +} + +export async function PUT(request, { params }) { + try { + const body = await request.json(); + const updated = updateAssignment(params.id, body); + if (!updated) return NextResponse.json({ error: "Assignment not found." }, { status: 404 }); + return NextResponse.json(updated); + } catch (e) { + return NextResponse.json({ error: String(e.message || e) }, { status: 400 }); + } +} + +export async function DELETE(request, { params }) { + const ok = deleteAssignment(params.id); + if (!ok) return NextResponse.json({ error: "Assignment not found." }, { status: 404 }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/assignments/route.js b/app/api/assignments/route.js new file mode 100644 index 0000000..f4072fe --- /dev/null +++ b/app/api/assignments/route.js @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { listAssignments, createAssignment } from "@/lib/store"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ assignments: listAssignments() }); +} + +export async function POST(request) { + try { + const body = await request.json(); + if (!body || typeof body !== "object") throw new Error("Missing assignment body."); + const record = createAssignment(body); + return NextResponse.json(record, { status: 201 }); + } catch (e) { + return NextResponse.json({ error: String(e.message || e) }, { status: 400 }); + } +} diff --git a/app/api/fetch-url/route.js b/app/api/fetch-url/route.js new file mode 100644 index 0000000..87bb6e9 --- /dev/null +++ b/app/api/fetch-url/route.js @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { htmlToText, extractTitle } from "@/lib/html-to-text"; + +export const dynamic = "force-dynamic"; + +export async function POST(request) { + try { + const { url } = await request.json(); + let target; + try { + target = new URL(String(url || "").trim()); + } catch { + throw new Error("That doesn't look like a valid URL. Include the full address, e.g. https://example.com/article"); + } + if (!/^https?:$/.test(target.protocol)) throw new Error("Only http and https URLs are supported."); + + const res = await fetch(target.toString(), { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; MrDrewsAssignmentCreator/1.0)", + "Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8", + }, + redirect: "follow", + signal: AbortSignal.timeout(20000), + }); + if (!res.ok) throw new Error(`The page returned an error (HTTP ${res.status}). It may be behind a login or blocking automated access.`); + + const contentType = res.headers.get("content-type") || ""; + const raw = await res.text(); + let text; + if (contentType.includes("text/plain")) { + text = raw; + } else { + text = htmlToText(raw); + } + text = text.slice(0, 200000); + if (text.trim().length < 200) { + throw new Error("Very little readable text was found on that page. It may be mostly images or load its content with JavaScript. Try copying the text and pasting it instead."); + } + return NextResponse.json({ title: extractTitle(raw), text, chars: text.length }); + } catch (e) { + const msg = e?.name === "TimeoutError" ? "Timed out fetching that page (20s)." : String(e.message || e); + return NextResponse.json({ error: msg }, { status: 400 }); + } +} diff --git a/app/api/generate/route.js b/app/api/generate/route.js new file mode 100644 index 0000000..b75e687 --- /dev/null +++ b/app/api/generate/route.js @@ -0,0 +1,99 @@ +// app/api/generate/route.js — the generation pipeline. +// The client calls this once per stage so the progress UI is honest: +// stage "analyze" -> content map of the source +// stage "generate" -> full assignment (with one automatic JSON-repair retry) +// stage "verify" -> per-question accuracy verdicts +// stage "question" -> regenerate one question / add a new one +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/store"; +import { chat } from "@/lib/providers"; +import { resolveGeneration } from "@/lib/model-caps"; +import { extractJson } from "@/lib/json-utils"; +import { analyzePrompt, generatePrompt, verifyPrompt, questionPrompt } from "@/lib/prompts"; +import { normalizeAssignment, normalizeQuestion } from "@/lib/schema"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 600; + +async function chatJson(settings, prompt, opts = {}) { + const raw = await chat(settings, { ...prompt, expectJson: true, ...opts }); + try { + return extractJson(raw); + } catch (firstErr) { + // One repair attempt: ask the same model to re-emit valid JSON. + const fixed = await chat(settings, { + system: "You convert text into strictly valid JSON. Output ONLY the corrected JSON with no commentary and no code fences.", + user: "The following was supposed to be a single valid JSON object but is malformed. Re-emit it as strictly valid JSON, preserving all content:\n\n" + String(raw).slice(0, 60000), + expectJson: true, + temperature: 0, + }); + try { + return extractJson(fixed); + } catch { + throw firstErr; + } + } +} + +export async function POST(request) { + try { + const body = await request.json(); + const { stage, source, config } = body; + const settings = getSettings(); + // Auto mode sizes the source budget to the selected model's context window. + const { maxSourceChars } = await resolveGeneration(settings); + + if (!source || String(source).trim().length < 100) { + throw new Error("The source material is too short to build a quality assignment from (minimum ~100 characters)."); + } + + if (stage === "analyze") { + const prompt = analyzePrompt({ source, config, maxSourceChars }); + const analysis = await chatJson(settings, prompt, { maxTokens: 2500 }); + return NextResponse.json({ analysis }); + } + + if (stage === "generate") { + const prompt = generatePrompt({ source, analysis: body.analysis, config, maxSourceChars }); + const raw = await chatJson(settings, prompt); + const assignment = normalizeAssignment(raw, config); + if (!assignment.questions.length) { + throw new Error("The model did not return any usable questions. Try again, or switch to a stronger model in Settings."); + } + return NextResponse.json({ assignment }); + } + + if (stage === "verify") { + const questions = body.questions || []; + if (!questions.length) throw new Error("No questions to verify."); + const prompt = verifyPrompt({ source, questions, config, maxSourceChars }); + const raw = await chatJson(settings, prompt, { temperature: 0.1 }); + const results = Array.isArray(raw?.results) ? raw.results : []; + const byId = {}; + for (const r of results) { + if (!r || !r.id) continue; + const verdict = r.verdict === "pass" ? "pass" : "warn"; + const note = [r.issue, r.suggestedFix ? "Suggested fix: " + r.suggestedFix : ""].filter(Boolean).join(" ").trim(); + byId[r.id] = { status: verdict, note: verdict === "pass" ? "" : (note || "The reviewer flagged this question — double-check it.") }; + } + return NextResponse.json({ verifications: byId }); + } + + if (stage === "question") { + const { type, note, existingQuestions, replacing } = body; + const prompt = questionPrompt({ source, config, existingQuestions, type, note, replacing, maxSourceChars }); + const raw = await chatJson(settings, prompt, { maxTokens: 2500 }); + let candidate = raw; + if (Array.isArray(raw)) candidate = raw[0]; + else if (Array.isArray(raw?.questions) && raw.questions.length) candidate = raw.questions[0]; + else if (raw?.question && typeof raw.question === "object") candidate = raw.question; + const question = normalizeQuestion(candidate); + if (!question) throw new Error("The model returned a question in an unexpected shape. Try again."); + return NextResponse.json({ question }); + } + + throw new Error("Unknown stage: " + stage); + } catch (e) { + return NextResponse.json({ error: String(e.message || e) }, { status: 400 }); + } +} diff --git a/app/api/providers/route.js b/app/api/providers/route.js new file mode 100644 index 0000000..6bbab6c --- /dev/null +++ b/app/api/providers/route.js @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { mergeSettings } from "@/lib/store"; +import { listModels, testConnection } from "@/lib/providers"; +import { resolveGeneration } from "@/lib/model-caps"; + +export const dynamic = "force-dynamic"; + +// POST { action: "models" | "test" | "defaults", provider, settings } +// Settings come from the client form so you can test before saving. +export async function POST(request) { + try { + const { action, provider, settings } = await request.json(); + const merged = mergeSettings(settings); + if (action === "models") { + const models = await listModels(merged, provider); + return NextResponse.json({ models }); + } + if (action === "test") { + const result = await testConnection(merged, provider); + return NextResponse.json(result); + } + if (action === "defaults") { + const probe = provider ? { ...merged, provider } : merged; + const resolved = await resolveGeneration(probe); + return NextResponse.json(resolved); + } + throw new Error("Unknown action."); + } catch (e) { + return NextResponse.json({ error: String(e.message || e) }, { status: 400 }); + } +} diff --git a/app/api/settings/route.js b/app/api/settings/route.js new file mode 100644 index 0000000..b34d6a3 --- /dev/null +++ b/app/api/settings/route.js @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getSettings, saveSettings } from "@/lib/store"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json(getSettings()); +} + +export async function PUT(request) { + try { + const body = await request.json(); + const saved = saveSettings(body); + return NextResponse.json(saved); + } catch (e) { + return NextResponse.json({ error: String(e.message || e) }, { status: 400 }); + } +} diff --git a/app/editor/[id]/page.jsx b/app/editor/[id]/page.jsx new file mode 100644 index 0000000..7f763b8 --- /dev/null +++ b/app/editor/[id]/page.jsx @@ -0,0 +1,367 @@ +"use client"; +// app/editor/[id]/page.jsx — review and refine an assignment, then export it. +import { useEffect, useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import QuestionCard from "@/components/QuestionCard"; +import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema"; +import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter"; + +export default function EditorPage() { + const { id } = useParams(); + const router = useRouter(); + + const [a, setA] = useState(null); + const [loadErr, setLoadErr] = useState(""); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [toast, setToast] = useState(""); + const [busyQ, setBusyQ] = useState(null); + const [verifying, setVerifying] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [error, setError] = useState(""); + const [profile, setProfile] = useState({}); + const toastTimer = useRef(null); + + useEffect(() => { + fetch("/api/assignments/" + id) + .then(async (r) => { + const data = await r.json(); + if (!r.ok) throw new Error(data.error || "Could not load this assignment."); + setA(data); + }) + .catch((e) => setLoadErr(String(e.message || e))); + fetch("/api/settings") + .then((r) => r.json()) + .then((s) => setProfile(s?.profile || {})) + .catch(() => {}); + }, [id]); + + useEffect(() => { + function onBeforeUnload(e) { + if (dirty) { e.preventDefault(); e.returnValue = ""; } + } + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [dirty]); + + function showToast(msg) { + setToast(msg); + clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToast(""), 2400); + } + + function patch(p) { + setA((cur) => ({ ...cur, ...p })); + setDirty(true); + } + + function setQuestion(i, q) { + setA((cur) => { + const questions = [...cur.questions]; + questions[i] = q; + return { ...cur, questions }; + }); + setDirty(true); + } + + function moveQuestion(i, dir) { + setA((cur) => { + const questions = [...cur.questions]; + const j = i + dir; + if (j < 0 || j >= questions.length) return cur; + [questions[i], questions[j]] = [questions[j], questions[i]]; + return { ...cur, questions }; + }); + setDirty(true); + } + + function deleteQuestion(i) { + if (!confirm("Delete question " + (i + 1) + "?")) return; + setA((cur) => ({ ...cur, questions: cur.questions.filter((_, j) => j !== i) })); + setDirty(true); + } + + async function save(silent) { + setSaving(true); + setError(""); + try { + const res = await fetch("/api/assignments/" + id, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(a), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Save failed."); + setA(data); + setDirty(false); + if (!silent) showToast("Saved"); + } catch (e) { + setError(String(e.message || e)); + } finally { + setSaving(false); + } + } + + async function regenerateQuestion(i, note) { + const q = a.questions[i]; + setBusyQ(q.id); + setError(""); + try { + const data = await postJson("/api/generate", { + stage: "question", + source: a.source?.text || "", + config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty }, + type: q.type, + note, + replacing: { question: q.question }, + existingQuestions: a.questions.filter((_, j) => j !== i).map((x) => ({ question: x.question })), + }); + const next = { ...data.question, points: q.points }; + setQuestion(i, next); + showToast("Question " + (i + 1) + " regenerated"); + } catch (e) { + setError(String(e.message || e)); + } finally { + setBusyQ(null); + } + } + + async function addQuestion(type, withAI) { + setAddOpen(false); + if (!withAI) { + setA((cur) => ({ ...cur, questions: [...cur.questions, blankQuestion(type)] })); + setDirty(true); + return; + } + setBusyQ("__new__"); + setError(""); + try { + const data = await postJson("/api/generate", { + stage: "question", + source: a.source?.text || "", + config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty }, + type, + existingQuestions: a.questions.map((x) => ({ question: x.question })), + }); + setA((cur) => ({ ...cur, questions: [...cur.questions, data.question] })); + setDirty(true); + showToast("Question added"); + } catch (e) { + setError(String(e.message || e)); + } finally { + setBusyQ(null); + } + } + + async function reverify() { + setVerifying(true); + setError(""); + try { + const data = await postJson("/api/generate", { + stage: "verify", + source: a.source?.text || "", + config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty }, + questions: a.questions, + }); + setA((cur) => ({ + ...cur, + questions: cur.questions.map((q) => + data.verifications[q.id] + ? { ...q, verification: data.verifications[q.id] } + : { ...q, verification: { status: "unchecked", note: "" } } + ), + })); + setDirty(true); + const warns = Object.values(data.verifications).filter((v) => v.status === "warn").length; + showToast(warns + ? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged` + : "Accuracy check done — all clear" + ); + } catch (e) { + setError(String(e.message || e)); + } finally { + setVerifying(false); + } + } + + function doExport(kind, who) { + setExportOpen(false); + const opts = who === "packet" ? { packet: true, profile } : { teacher: who === true, profile }; + try { + if (kind === "txt") { exportTxt(a, opts); showToast("Downloaded .txt"); } + if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); } + if (kind === "print") { exportPrint(a, opts); } + if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); } + } catch (e) { + setError(String(e.message || e)); + } + } + + if (loadErr) { + return ( +
+

Couldn’t open that assignment

+

{loadErr}

+ +
+ ); + } + + if (!a) { + return ( +
+
+
+
+
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+ ))} +
+ ); + } + + const warnCount = a.questions.filter((q) => q.verification?.status === "warn").length; + const uncheckedCount = a.questions.filter((q) => !q.verification || q.verification.status === "unchecked").length; + + return ( +
+
+
+ patch({ title: e.target.value })} + aria-label="Assignment title" + style={{ + fontFamily: "var(--font-display)", fontSize: "1.6rem", fontWeight: 700, + border: "1.5px solid transparent", background: "transparent", + padding: "4px 8px", marginLeft: -8, borderRadius: 8, width: "100%", + transition: "border-color 0.15s, background 0.15s", + }} + onFocus={(e) => { e.target.style.borderColor = "var(--line-strong)"; e.target.style.background = "var(--field-bg)"; }} + onBlur={(e) => { e.target.style.borderColor = "transparent"; e.target.style.background = "transparent"; }} + /> +

+ {[a.assignmentType?.replace("_", " "), a.gradeLevel, a.subject].filter(Boolean).join(" · ")} · {a.questions.length} questions · {totalPoints(a.questions)} points + {a.source?.name ? <> · from {a.source.name} : null} +

+
+
+
+ + {exportOpen && ( +
+
Student version
+
+ + + + +
+
Teacher version (answer key)
+
+ + + + +
+
Complete packet — student + answer key
+
+ + +
+
+ )} +
+ + +
+
+ + {error &&
{error}
} + {warnCount > 0 && ( +
+ {warnCount} question{warnCount === 1 ? "" : "s"} flagged by the accuracy check. Look for the ⚠ stamps below — each has a reviewer note. Edit or regenerate those questions, then re-run the check. +
+ )} + {warnCount === 0 && uncheckedCount === 0 && a.questions.length > 0 && ( +
✓ Every question passed the accuracy check against your source.
+ )} + +