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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user