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
|
|
@ -1,10 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { documentsToText } from "@/lib/documents";
|
||||
import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
|
||||
import { setDeckSeed } from "@/lib/deck-seed";
|
||||
import {
|
||||
assembleSolution,
|
||||
deleteHomework,
|
||||
getHomework,
|
||||
homeworkStore,
|
||||
|
|
@ -18,6 +21,7 @@ import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
|||
import { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
import { clearWizardDraft } from "@/lib/wizard-draft";
|
||||
|
||||
import { HomeworkCheckResult } from "./HomeworkCheckResult";
|
||||
import { HomeworkGuide } from "./HomeworkGuide";
|
||||
import { HomeworkInput } from "./HomeworkInput";
|
||||
import { HomeworkLoading } from "./HomeworkLoading";
|
||||
|
|
@ -32,6 +36,7 @@ type View = { kind: "start" } | { kind: "loading" } | { kind: "record"; record:
|
|||
/** The /homework screen: give it an assignment, pick a mode, get a result. */
|
||||
export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const records = useSyncExternalStore(
|
||||
homeworkStore.subscribe,
|
||||
homeworkStore.getSnapshot,
|
||||
|
|
@ -45,6 +50,8 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
|
||||
const [mode, setMode] = useState<HomeworkMode>("make");
|
||||
const [text, setText] = useState("");
|
||||
/** Mode "check": the student's own attempt at the assignment. */
|
||||
const [solution, setSolution] = useState("");
|
||||
const [view, setView] = useState<View>({ kind: "start" });
|
||||
const [progress, setProgress] = useState<HomeworkProgress>({ stage: "vision", message: "" });
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
|
|
@ -68,11 +75,16 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
...intake.images.map((image) => image.dataUrl),
|
||||
...documentImages.map((image) => image.dataUrl),
|
||||
].slice(0, MAX_IMAGES_TO_SEND);
|
||||
return { mode: modeValue, images, text: mergedText };
|
||||
return {
|
||||
mode: modeValue,
|
||||
images,
|
||||
text: mergedText,
|
||||
solution: modeValue === "check" ? solution.trim() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const run = useCallback(
|
||||
async (input: { mode: HomeworkMode; images: string[]; text: string; recordId?: string }) => {
|
||||
async (input: { mode: HomeworkMode; images: string[]; text: string; solution?: string; recordId?: string }) => {
|
||||
setError(null);
|
||||
setView({ kind: "loading" });
|
||||
const vision = input.images.length > 0;
|
||||
|
|
@ -82,7 +94,9 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
? t("homework.loading.reading")
|
||||
: input.mode === "make"
|
||||
? t("homework.loading.writing")
|
||||
: t("homework.loading.planning"),
|
||||
: input.mode === "check"
|
||||
? t("homework.loading.checking")
|
||||
: t("homework.loading.planning"),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
|
|
@ -94,12 +108,13 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
mode: input.mode,
|
||||
images: input.images.length > 0 ? input.images : undefined,
|
||||
text: input.text || undefined,
|
||||
solution: input.solution,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
// The server hands back the assignment as text (the transcription when
|
||||
// photos were used), so “Make it again” works without the files.
|
||||
// photos were used), so “Redo” works without the files.
|
||||
const sourceText = homework.sourceText ?? input.text;
|
||||
const result: NewHomeworkResult =
|
||||
homework.mode === "make"
|
||||
|
|
@ -110,13 +125,25 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
sourceText,
|
||||
answer: homework.answer,
|
||||
}
|
||||
: {
|
||||
mode: "guide",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
steps: homework.steps,
|
||||
};
|
||||
: homework.mode === "check"
|
||||
? {
|
||||
mode: "check",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
verdict: homework.verdict,
|
||||
correct: homework.correct,
|
||||
wrong: homework.wrong,
|
||||
corrected: homework.corrected,
|
||||
attempt: input.solution,
|
||||
}
|
||||
: {
|
||||
mode: "guide",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
steps: homework.steps,
|
||||
};
|
||||
|
||||
const record = input.recordId ? updateHomework(input.recordId, result) : saveHomework(result);
|
||||
if (!record) throw new ApiError(t("homework.error.body"), "bad_response");
|
||||
|
|
@ -141,19 +168,43 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
|
||||
/** `modeOverride` lets the phone flow start straight from a tapped mode card. */
|
||||
const submit = (modeOverride?: HomeworkMode) => {
|
||||
const payload = buildPayload(modeOverride ?? mode);
|
||||
const target = modeOverride ?? mode;
|
||||
if (target === "check" && !solution.trim()) {
|
||||
setError({ message: t("homework.solution.required") });
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload(target);
|
||||
void run(payload);
|
||||
};
|
||||
|
||||
/** Re-run from the stored assignment text (files were never stored). */
|
||||
const again = (record: HomeworkRecord) => {
|
||||
setAgainLoading(true);
|
||||
void run({ mode: record.mode, images: [], text: record.sourceText, recordId: record.id });
|
||||
void run({
|
||||
mode: record.mode,
|
||||
images: [],
|
||||
text: record.sourceText,
|
||||
solution: record.mode === "check" ? record.attempt : undefined,
|
||||
recordId: record.id,
|
||||
});
|
||||
};
|
||||
|
||||
/** Hand the finished content to the New deck flow with the options pre-filled. */
|
||||
const turnIntoDeck = (record: HomeworkRecord) => {
|
||||
const content =
|
||||
record.mode === "make"
|
||||
? record.answer
|
||||
: record.mode === "check"
|
||||
? record.corrected
|
||||
: assembleSolution(record);
|
||||
setDeckSeed({ text: content, difficulty: "medium", count: 10 });
|
||||
router.push("/create");
|
||||
};
|
||||
|
||||
const newAssignment = () => {
|
||||
intake.clear();
|
||||
setText("");
|
||||
setSolution("");
|
||||
setError(null);
|
||||
clearWizardDraft("homework");
|
||||
setView({ kind: "start" });
|
||||
|
|
@ -164,19 +215,35 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
}
|
||||
|
||||
if (view.kind === "record") {
|
||||
return view.record.mode === "make" ? (
|
||||
<HomeworkResult
|
||||
record={view.record}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
) : (
|
||||
if (view.record.mode === "make") {
|
||||
return (
|
||||
<HomeworkResult
|
||||
record={view.record}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
onToDeck={() => turnIntoDeck(view.record)}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.record.mode === "check") {
|
||||
return (
|
||||
<HomeworkCheckResult
|
||||
record={view.record}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
onToDeck={() => turnIntoDeck(view.record)}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HomeworkGuide
|
||||
record={view.record}
|
||||
onClose={() => setView({ kind: "start" })}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
onToDeck={() => turnIntoDeck(view.record)}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
);
|
||||
|
|
@ -188,6 +255,8 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
onModeChange={setMode}
|
||||
text={text}
|
||||
onTextChange={setText}
|
||||
solution={solution}
|
||||
onSolutionChange={setSolution}
|
||||
intake={intake}
|
||||
onSubmit={submit}
|
||||
error={error}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue