Phase 3: Check my work, follow-ups and turn-into-deck
Check my work (third homework mode) - One model call with its own system prompt marks the student's own attempt: verdict, what's right, each issue with why + fix, and the corrected version that keeps everything the student got right. Unasked-for extras are never errors. The attempt is typed/pasted (photos of handwriting are documented as unsupported) and stored in the record so Redo re-checks the same work. - Server: mode "check" + solution on POST /api/homework (zod, 400 server.needSolution without one) and the same NDJSON stream; new checkHomework() pipeline with repair-once validation. Follow-ups - New POST /api/homework/refine (plain JSON, RATE_LIMIT_REFINE, zod, auth before body): shorter / more detail / explain, plain text back, language preserved. - Shorter + More detail + Original rewrite the finished answer and the corrected version in place (FollowUpBar); every guide step has Explain step N differently; failed calls say so without losing text. Turn into a study deck - Any result hands its content (answer, corrected version or assembled guide) to New deck through a one-shot sessionStorage seed with the options step pre-filled. UI - Phone step 2 gains the third card: tapping it reveals the solution field + submit; desktop has three cards and the solution field shows with the mode. Homework history labels and share/card previews cover the new mode. Verified: new phase-3 suite 20/20 against a stub Ollama (check payload, missing-solution 400, refine validation, phone + desktop check flows, shorter follow-up with restore, guide explain, deck hand-off) plus regression 74/74, mobile 52/52, declutter 116/116, wizard 80/80, animation 8/8, native 35/35, phase1 38/38, phase2 43/43, i18n 25/25. Key parity 734/734. Lint + build clean. SW freezmo-v27.
This commit is contained in:
parent
a8a8bc0477
commit
4513364019
22 changed files with 947 additions and 56 deletions
90
app/api/homework/refine/route.ts
Normal file
90
app/api/homework/refine/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { refineHomeworkText } from "@/lib/ai";
|
||||
import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
|
||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Homework follow-ups: rewrite one finished piece — shorter, more detail, or
|
||||
* a step explained differently. One short model call, plain JSON back.
|
||||
*/
|
||||
|
||||
const requestSchema = z.object({
|
||||
instruction: z.enum(["shorter", "more", "explain"]),
|
||||
text: z.string().min(1).max(20_000),
|
||||
/** The assignment, for context (optional). */
|
||||
context: z.string().max(8_000).optional(),
|
||||
language: z.string().max(60).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const locale = requestLocale(request);
|
||||
const crossOrigin = rejectCrossOrigin(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const oversized = rejectOversizedBody(request, 128 * 1024);
|
||||
if (oversized) return oversized;
|
||||
|
||||
const config = getServerConfig();
|
||||
const limit = rateLimit(`refine:${clientKey(request)}`, config.rateLimits.refine, config.rateLimits.windowMs);
|
||||
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
||||
|
||||
const auth = resolveAuth(config, request);
|
||||
if ("error" in auth) {
|
||||
return Response.json(
|
||||
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: translate(locale, "server.invalidRequest", {
|
||||
detail: parsed.error.issues
|
||||
.slice(0, 3)
|
||||
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
|
||||
.join("; "),
|
||||
}),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await refineHomeworkText({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
instruction: parsed.data.instruction,
|
||||
text: parsed.data.text,
|
||||
context: parsed.data.context,
|
||||
});
|
||||
return Response.json(
|
||||
{ text, model: resolveModelName(config.textModel, auth.auth.directCloud) },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
// Never log the text itself: it may contain the student's homework.
|
||||
console.error("[api/homework/refine]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
||||
return Response.json(
|
||||
{
|
||||
error: friendlyErrorMessage(error, auth.auth.baseUrl, locale),
|
||||
kind: error instanceof OllamaError ? error.kind : "unknown",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { buildHomeworkGuide, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai";
|
||||
import { buildHomeworkGuide, checkHomework, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai";
|
||||
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
|
||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
|
|
@ -22,6 +22,8 @@ const requestSchema = z.object({
|
|||
mode: homeworkModeSchema,
|
||||
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
/** Mode "check": the student's own attempt at the assignment. */
|
||||
solution: z.string().max(120_000).optional(),
|
||||
/** Language of the assignment (usually detected by the vision step). */
|
||||
language: z.string().max(60).optional(),
|
||||
});
|
||||
|
|
@ -80,12 +82,15 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const { mode, images, text, language } = parsed.data;
|
||||
const { mode, images, text, solution, language } = parsed.data;
|
||||
const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean);
|
||||
|
||||
if (imageList.length === 0 && !text?.trim()) {
|
||||
return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 });
|
||||
}
|
||||
if (mode === "check" && !solution?.trim()) {
|
||||
return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 });
|
||||
}
|
||||
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
|
|
@ -128,7 +133,14 @@ export async function POST(request: Request) {
|
|||
send({
|
||||
type: "stage",
|
||||
stage: "generation",
|
||||
message: translate(locale, mode === "make" ? "homework.loading.writing" : "homework.loading.planning"),
|
||||
message: translate(
|
||||
locale,
|
||||
mode === "make"
|
||||
? "homework.loading.writing"
|
||||
: mode === "check"
|
||||
? "homework.loading.checking"
|
||||
: "homework.loading.planning",
|
||||
),
|
||||
});
|
||||
|
||||
const meta = {
|
||||
|
|
@ -138,7 +150,30 @@ export async function POST(request: Request) {
|
|||
textModel: resolveModelName(config.textModel, auth.auth.directCloud),
|
||||
};
|
||||
|
||||
if (mode === "make") {
|
||||
if (mode === "check") {
|
||||
const result = await checkHomework({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
assignment: assignmentText,
|
||||
solution: solution!.trim(),
|
||||
language: detectedLanguage,
|
||||
signal: abort.signal,
|
||||
});
|
||||
send({
|
||||
type: "homework",
|
||||
homework: {
|
||||
mode: "check",
|
||||
title: result.title,
|
||||
language: result.language || detectedLanguage,
|
||||
verdict: result.verdict,
|
||||
correct: result.correct,
|
||||
wrong: result.wrong,
|
||||
corrected: result.corrected,
|
||||
sourceText: assignmentText,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
} else if (mode === "make") {
|
||||
const result = await makeHomework({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue