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
|
|
@ -42,6 +42,8 @@ TEXT_MODEL=gpt-oss:120b-cloud
|
|||
# RATE_LIMIT_GENERATE=12
|
||||
# RATE_LIMIT_GRADE=40
|
||||
# RATE_LIMIT_HOMEWORK=12
|
||||
# Homework follow-ups (short rewrites).
|
||||
# RATE_LIMIT_REFINE=30
|
||||
# RATE_LIMIT_HEALTH=90
|
||||
# Encrypted sync (no model calls, but shouldn't be hammered).
|
||||
# RATE_LIMIT_SYNC=60
|
||||
|
|
|
|||
41
README.md
41
README.md
|
|
@ -123,20 +123,27 @@ no database, no data leaving your machine except the requests you send to Ollama
|
|||
|
||||
- Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text
|
||||
file; photos go through the same vision model and language guard that notes do
|
||||
- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and
|
||||
**Explain how to make this homework** turns it into a guided walkthrough
|
||||
- Three modes: **Just make it** returns the finished homework (the answer only, ready to copy),
|
||||
**Explain how to make this homework** turns it into a guided walkthrough, and **Check my work**
|
||||
marks the student's own attempt — what's right, what's wrong and why, plus a corrected version
|
||||
(the attempt is typed or pasted; photos of handwriting are not supported yet, and the UI says so)
|
||||
- **Follow-ups on a result**: *Shorter*, *More detail* and back to the *Original* rewrite the
|
||||
finished text in place; inside a guide every step has *Explain step N differently*
|
||||
- **Turn into a study deck** on any result opens New deck with the content (the answer, the
|
||||
corrected version or the assembled guide) and the options step pre-filled
|
||||
- On phones this is a **2-step flow**: the assignment first (same simplified input as the
|
||||
deck flow, with its own step headline, formats hint, big add area and empty placeholder),
|
||||
then the two mode cards under a short preview — tapping a card starts it immediately, so
|
||||
there is no separate button. Step 1 starts with *Cancel* and the flat action bar matches
|
||||
the deck wizard. Desktop keeps the select-then-confirm layout.
|
||||
then the mode cards under a short preview — tapping a card starts it immediately (the check
|
||||
card first reveals the solution field), so there is no separate button. Step 1 starts with
|
||||
*Cancel* and the flat action bar matches the deck wizard. Desktop keeps the select-then-confirm
|
||||
layout.
|
||||
- The guide is one step per screen in the same snap-feed feel as the study feed: what to do and
|
||||
why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint**
|
||||
built from the normal activity components and graded through `/api/grade`
|
||||
- The last screen assembles the solution from the step answers, with a copy button
|
||||
- Results live in `freezmo.homework.v1` — the homework screen's history and the library's
|
||||
**Homework** tab — where you can reopen or delete them; **Make it again** re-runs from the
|
||||
stored assignment text, and original files are never saved
|
||||
**Homework** tab — where you can reopen or delete them; **Redo** re-runs from the
|
||||
stored assignment text (and the stored attempt for checks), and original files are never saved
|
||||
- The answer and the guide are always written in the **language of the assignment**, never
|
||||
translated into the interface language
|
||||
|
||||
|
|
@ -423,6 +430,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
|
|||
| `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` |
|
||||
| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` |
|
||||
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
|
||||
| `POST /api/homework/refine` | 30 per 5 minutes | `RATE_LIMIT_REFINE` |
|
||||
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
|
||||
| `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` |
|
||||
| `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` |
|
||||
|
|
@ -719,7 +727,8 @@ scripts/
|
|||
| Route | Body | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` |
|
||||
| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
|
||||
| `POST /api/homework` | `{ mode: "make" \| "guide" \| "check", images?: string[], text?, solution?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
|
||||
| `POST /api/homework/refine` | `{ instruction: "shorter" \| "more" \| "explain", text, context?, language? }` | `{ text }` — one rewritten piece, plain text |
|
||||
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
|
||||
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` |
|
||||
| `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` |
|
||||
|
|
@ -993,6 +1002,22 @@ Things I chose while building, with the reasoning:
|
|||
second copy. Result records store the assignment text — the vision transcription when photos
|
||||
were used — so “Make it again” works without storing a single original file.
|
||||
|
||||
42. **Check my work is a checker, not a second writer.** The third homework mode sends the
|
||||
assignment and the student's attempt to one model call with its own system prompt
|
||||
(`HOMEWORK_CHECK_SYSTEM_PROMPT`): the output is a verdict, a list of what is right, a list of
|
||||
issues with *why* and *fix*, and the corrected version. Unasked-for extras are explicitly
|
||||
never errors, and the corrected version keeps everything the student got right as they wrote
|
||||
it. The attempt is typed or pasted — supporting photos of handwriting would need a second
|
||||
vision pass and a way to separate assignment from attempt in the same image, so the UI says
|
||||
plainly that it isn't supported yet. Follow-ups are a separate, cheaper route
|
||||
(`POST /api/homework/refine`, plain JSON, `RATE_LIMIT_REFINE`) shared by the result, the check
|
||||
and each guide step; they rewrite one piece and never touch the stored record. “Turn into a
|
||||
study deck” hands the content to the New deck flow through sessionStorage (one-shot, like the
|
||||
share target), with the options step pre-filled. Verified by the phase-3 suite (20 checks
|
||||
against a stub Ollama: the check payload, the missing-solution 400, refine validation, the
|
||||
phone and desktop check flows, the shorter follow-up with restore, the guide's explain action,
|
||||
and the deck hand-off).
|
||||
|
||||
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
|
||||
flow and a 2-step homework flow that share the exact state and handlers with the desktop
|
||||
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);
|
||||
|
|
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -41,10 +41,12 @@ export function HomeworkCard({
|
|||
const preview =
|
||||
record.mode === "make"
|
||||
? truncate(record.answer.replace(/\s+/g, " ").trim(), 150)
|
||||
: truncate(
|
||||
record.steps.map((step) => step.title).join(" · "),
|
||||
150,
|
||||
);
|
||||
: record.mode === "check"
|
||||
? truncate(record.verdict.replace(/\s+/g, " ").trim(), 150)
|
||||
: truncate(
|
||||
record.steps.map((step) => step.title).join(" · "),
|
||||
150,
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { useI18n } from "@/lib/i18n";
|
|||
import { stepVariants } from "@/lib/motion";
|
||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||
import { takePendingShare } from "@/lib/share-pending";
|
||||
import { takeDeckSeed } from "@/lib/deck-seed";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
|
|
@ -308,6 +309,15 @@ export function UploadScreen() {
|
|||
if (pending.files.length > 0) void addFiles(pending.files);
|
||||
}, [addFiles]);
|
||||
|
||||
/* Homework → "Turn into a study deck": content + options pre-filled. */
|
||||
useEffect(() => {
|
||||
const seed = takeDeckSeed();
|
||||
if (!seed) return;
|
||||
setText(seed.text);
|
||||
if (seed.difficulty) setDifficulty(seed.difficulty);
|
||||
if (seed.count) setCount(seed.count);
|
||||
}, []);
|
||||
|
||||
/* Persist the draft while the user works (debounced, best-effort). */
|
||||
useEffect(() => {
|
||||
if (phase === "loading") return;
|
||||
|
|
|
|||
76
components/homework/FollowUpBar.tsx
Normal file
76
components/homework/FollowUpBar.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { refineHomework } from "@/lib/client-api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Follow-up actions for one finished piece of homework: shorter, more detail,
|
||||
* or back to the original. Used on the make result and the corrected version
|
||||
* of a check.
|
||||
*/
|
||||
export function FollowUpBar({
|
||||
text,
|
||||
context,
|
||||
onReplace,
|
||||
onRestore,
|
||||
canRestore,
|
||||
}: {
|
||||
text: string;
|
||||
context?: string;
|
||||
onReplace: (next: string) => void;
|
||||
onRestore?: () => void;
|
||||
canRestore?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [busy, setBusy] = useState<"shorter" | "more" | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const run = async (instruction: "shorter" | "more") => {
|
||||
if (busy) return;
|
||||
setBusy(instruction);
|
||||
setFailed(false);
|
||||
try {
|
||||
const next = await refineHomework({ instruction, text, context });
|
||||
onReplace(next);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
loading={busy === "shorter"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => void run("shorter")}
|
||||
>
|
||||
{t("homework.followup.shorter")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
loading={busy === "more"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => void run("more")}
|
||||
>
|
||||
{t("homework.followup.more")}
|
||||
</Button>
|
||||
{busy ? <span className="text-[12px] text-[var(--text-subtle)]">{t("homework.followup.working")}</span> : null}
|
||||
{canRestore && onRestore ? (
|
||||
<Button size="sm" variant="ghost" className="touch-target" disabled={Boolean(busy)} onClick={onRestore}>
|
||||
{t("homework.followup.restore")}
|
||||
</Button>
|
||||
) : null}
|
||||
{failed ? <span className="text-[12px] font-medium text-rose-600 dark:text-rose-300">{t("homework.followup.failed")}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
components/homework/HomeworkCheckResult.tsx
Normal file
113
components/homework/HomeworkCheckResult.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkCheckRecord } from "@/lib/schemas";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { FollowUpBar } from "./FollowUpBar";
|
||||
|
||||
/** Mode 3 result: verdict, what's right, what needs fixing, corrected version. */
|
||||
export function HomeworkCheckResult({
|
||||
record,
|
||||
onAgain,
|
||||
onNew,
|
||||
onToDeck,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkCheckRecord;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
onToDeck: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [corrected, setCorrected] = useState(record.corrected);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("homework.check.title")}
|
||||
</p>
|
||||
<h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance">
|
||||
{record.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section className="card space-y-1.5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("homework.check.verdict")}
|
||||
</p>
|
||||
<p className="text-[15px] leading-relaxed text-[var(--text)]">{record.verdict}</p>
|
||||
</section>
|
||||
|
||||
{record.correct.length > 0 ? (
|
||||
<section className="rounded-2xl border border-emerald-200/80 bg-emerald-50/90 p-4 dark:border-emerald-400/30 dark:bg-emerald-500/10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-200">
|
||||
{t("homework.check.correct")}
|
||||
</p>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 text-[14px] leading-relaxed text-emerald-900 dark:text-emerald-100">
|
||||
{record.correct.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{record.wrong.length > 0 ? (
|
||||
<section className="rounded-2xl border border-rose-200/80 bg-rose-50/90 p-4 dark:border-rose-400/30 dark:bg-rose-500/10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-800 dark:text-rose-200">
|
||||
{t("homework.check.wrong")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-3">
|
||||
{record.wrong.map((entry, index) => (
|
||||
<li key={index} className="space-y-0.5 text-[14px] leading-relaxed text-rose-900 dark:text-rose-100">
|
||||
<p className="font-semibold">{entry.issue}</p>
|
||||
<p>{entry.why}</p>
|
||||
{entry.fix ? (
|
||||
<p>
|
||||
<span className="font-bold">{t("homework.check.fix")}: </span>
|
||||
{entry.fix}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<article className="card space-y-3 p-4 sm:p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("homework.check.corrected")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{corrected}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyButton text={corrected} />
|
||||
<FollowUpBar
|
||||
text={corrected}
|
||||
context={record.sourceText}
|
||||
onReplace={setCorrected}
|
||||
onRestore={() => setCorrected(record.corrected)}
|
||||
canRestore={corrected !== record.corrected}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.followup.redo")}
|
||||
</Button>
|
||||
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
|
||||
{t("homework.toDeck")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { assembleSolution } from "@/lib/homework";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
|
||||
import { useWakeLock } from "@/lib/use-wake-lock";
|
||||
import { refineHomework } from "@/lib/client-api";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
|
|
@ -25,12 +26,14 @@ export function HomeworkGuide({
|
|||
onClose,
|
||||
onAgain,
|
||||
onNew,
|
||||
onToDeck,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkGuideRecord;
|
||||
onClose: () => void;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
onToDeck: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -40,8 +43,30 @@ export function HomeworkGuide({
|
|||
const slideCount = steps.length + 1; // + the solution slide
|
||||
const [index, setIndex] = useState(0);
|
||||
const [revealed, setRevealed] = useState<Record<number, number>>({});
|
||||
/** "Explain this step differently" overrides, per step index. */
|
||||
const [explained, setExplained] = useState<Record<number, string>>({});
|
||||
const [explaining, setExplaining] = useState<number | null>(null);
|
||||
const [explainFailed, setExplainFailed] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const explainStep = async (stepIndex: number) => {
|
||||
if (explaining !== null) return;
|
||||
setExplaining(stepIndex);
|
||||
setExplainFailed(null);
|
||||
try {
|
||||
const next = await refineHomework({
|
||||
instruction: "explain",
|
||||
text: steps[stepIndex].explanation,
|
||||
context: record.sourceText,
|
||||
});
|
||||
setExplained((current) => ({ ...current, [stepIndex]: next }));
|
||||
} catch {
|
||||
setExplainFailed(stepIndex);
|
||||
} finally {
|
||||
setExplaining(null);
|
||||
}
|
||||
};
|
||||
|
||||
const goTo = useCallback(
|
||||
(target: number) => {
|
||||
const container = scrollRef.current;
|
||||
|
|
@ -164,8 +189,25 @@ export function HomeworkGuide({
|
|||
<PromptCard>
|
||||
<QuestionText>{step.title}</QuestionText>
|
||||
<p className="mt-2 text-[15px] leading-relaxed text-frost-700 dark:text-frost-200">
|
||||
{step.explanation}
|
||||
{explained[stepIndex] ?? step.explanation}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target"
|
||||
loading={explaining === stepIndex}
|
||||
disabled={explaining !== null}
|
||||
onClick={() => void explainStep(stepIndex)}
|
||||
>
|
||||
{t("homework.followup.step", { n: stepIndex + 1 })}
|
||||
</Button>
|
||||
{explainFailed === stepIndex ? (
|
||||
<span className="text-[12px] font-medium text-rose-600 dark:text-rose-300">
|
||||
{t("homework.followup.failed")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
{/* progressive hints */}
|
||||
|
|
@ -242,12 +284,16 @@ export function HomeworkGuide({
|
|||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={solution} />
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.result.again")}
|
||||
{t("homework.followup.redo")}
|
||||
</Button>
|
||||
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
|
||||
{t("homework.toDeck")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
||||
import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { WizardActionBar } from "@/components/WizardActionBar";
|
||||
import {
|
||||
|
|
@ -47,6 +47,7 @@ function describeExtraction(
|
|||
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
|
||||
{ id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
|
||||
{ id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
|
||||
{ id: "check", icon: <CheckCircleIcon className="size-5" />, titleKey: "homework.mode.check", subtitleKey: "homework.mode.checkDesc" },
|
||||
];
|
||||
|
||||
export function HomeworkInput({
|
||||
|
|
@ -54,6 +55,8 @@ export function HomeworkInput({
|
|||
onModeChange,
|
||||
text,
|
||||
onTextChange,
|
||||
solution,
|
||||
onSolutionChange,
|
||||
intake,
|
||||
onSubmit,
|
||||
error,
|
||||
|
|
@ -65,6 +68,9 @@ export function HomeworkInput({
|
|||
onModeChange: (mode: HomeworkMode) => void;
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
/** Mode "check": the student's own attempt. */
|
||||
solution: string;
|
||||
onSolutionChange: (solution: string) => void;
|
||||
intake: ReturnType<typeof useNotesIntake>;
|
||||
/** `modeOverride` lets the phone mode cards start the run they tapped. */
|
||||
onSubmit: (modeOverride?: HomeworkMode) => void;
|
||||
|
|
@ -80,6 +86,8 @@ export function HomeworkInput({
|
|||
const [step, setStep] = useState(1);
|
||||
const [stepDir, setStepDir] = useState<1 | -1>(1);
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
/** Phones: the check card reveals the solution field instead of starting. */
|
||||
const [checkOpen, setCheckOpen] = useState(false);
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const online = useOnline();
|
||||
|
|
@ -176,6 +184,29 @@ export function HomeworkInput({
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
/* Shared solution field: typed/pasted only (documented in the note). */
|
||||
const solutionField = (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-semibold text-[var(--text)]" htmlFor="homework-solution">
|
||||
{t("homework.solution.label")}
|
||||
</label>
|
||||
<textarea
|
||||
id="homework-solution"
|
||||
value={solution}
|
||||
onChange={(event) => onSolutionChange(event.target.value)}
|
||||
rows={5}
|
||||
placeholder={t("homework.solution.placeholder")}
|
||||
inputMode="text"
|
||||
enterKeyHint="done"
|
||||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
spellCheck
|
||||
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("homework.solution.note")}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* Shared mode card: desktop toggles the selection, the phone flow starts it. */
|
||||
const modeCard = (option: (typeof MODES)[number], onPick: () => void) => {
|
||||
const selected = mode === option.id;
|
||||
|
|
@ -479,12 +510,35 @@ export function HomeworkInput({
|
|||
{MODES.map((option) =>
|
||||
modeCard(option, () => {
|
||||
if (!canSubmit || !online) return;
|
||||
if (option.id === "check") {
|
||||
// Needs the solution first: reveal the field instead of
|
||||
// starting the run.
|
||||
onModeChange("check");
|
||||
setCheckOpen(true);
|
||||
return;
|
||||
}
|
||||
clearWizardDraft("homework");
|
||||
onModeChange(option.id);
|
||||
onSubmit(option.id);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
{checkOpen ? (
|
||||
<div className="space-y-3">
|
||||
{solutionField}
|
||||
<Button
|
||||
size="lg"
|
||||
className="touch-target w-full"
|
||||
disabled={!canSubmit || !solution.trim()}
|
||||
onClick={() => {
|
||||
clearWizardDraft("homework");
|
||||
onSubmit("check");
|
||||
}}
|
||||
>
|
||||
{t("homework.mode.check")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-center text-[12px] text-[var(--text-subtle)]">
|
||||
{online ? t("homework.phone.startHint") : t("offline.generate")}
|
||||
</p>
|
||||
|
|
@ -704,16 +758,23 @@ export function HomeworkInput({
|
|||
{/* ------------------------------------------------------- mode */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{MODES.map((option) => modeCard(option, () => onModeChange(option.id)))}
|
||||
</div>
|
||||
{mode === "check" ? solutionField : null}
|
||||
</section>
|
||||
|
||||
{/* -------------------------------------------------------- CTA */}
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" className="touch-target w-full" onClick={() => onSubmit()} disabled={!canSubmit} loading={intake.busy}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="touch-target w-full"
|
||||
onClick={() => onSubmit()}
|
||||
disabled={!canSubmit || (mode === "check" && !solution.trim())}
|
||||
loading={intake.busy}
|
||||
>
|
||||
<SparklesIcon className="size-5" />
|
||||
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
|
||||
{mode === "make" ? t("homework.start.make") : mode === "guide" ? t("homework.start.guide") : t("homework.mode.check")}
|
||||
</Button>
|
||||
<p className="text-center text-[12px] text-[var(--text-subtle)]">
|
||||
{canSubmit ? t("homework.source.ready") : t("homework.source.need")}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMakeRecord } from "@/lib/schemas";
|
||||
import { shareHomework } from "@/lib/share";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { FollowUpBar } from "./FollowUpBar";
|
||||
|
||||
/** Mode 1 result: the finished homework, nothing else. */
|
||||
export function HomeworkResult({
|
||||
record,
|
||||
onAgain,
|
||||
onNew,
|
||||
onToDeck,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkMakeRecord;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
onToDeck: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [answer, setAnswer] = useState(record.answer);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -32,24 +38,33 @@ export function HomeworkResult({
|
|||
</h1>
|
||||
</header>
|
||||
|
||||
<article className="card p-4 sm:p-5">
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">
|
||||
{record.answer}
|
||||
</p>
|
||||
<article className="card space-y-3 p-4 sm:p-5">
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{answer}</p>
|
||||
<FollowUpBar
|
||||
text={answer}
|
||||
context={record.sourceText}
|
||||
onReplace={setAnswer}
|
||||
onRestore={() => setAnswer(record.answer)}
|
||||
canRestore={answer !== record.answer}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={record.answer} />
|
||||
<Button variant="secondary" className="touch-target" onClick={() => void shareHomework(record)}>
|
||||
<CopyButton text={answer} />
|
||||
<Button variant="secondary" className="touch-target" onClick={() => void shareHomework({ ...record, answer })}>
|
||||
{t("deck.share")}
|
||||
</Button>
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.result.again")}
|
||||
{t("homework.followup.redo")}
|
||||
</Button>
|
||||
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
|
||||
{t("homework.toDeck")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
74
lib/ai.ts
74
lib/ai.ts
|
|
@ -9,13 +9,17 @@ import { needsLanguageRepair } from "./language";
|
|||
import {
|
||||
buildGenerationUserPrompt,
|
||||
buildGradingUserPrompt,
|
||||
buildHomeworkCheckUserPrompt,
|
||||
buildHomeworkGuideUserPrompt,
|
||||
buildHomeworkMakeUserPrompt,
|
||||
buildRefineUserPrompt,
|
||||
buildRepairPrompt,
|
||||
GENERATION_SYSTEM_PROMPT,
|
||||
GRADING_SYSTEM_PROMPT,
|
||||
HOMEWORK_CHECK_SYSTEM_PROMPT,
|
||||
HOMEWORK_GUIDE_SYSTEM_PROMPT,
|
||||
HOMEWORK_MAKE_SYSTEM_PROMPT,
|
||||
REFINE_SYSTEM_PROMPT,
|
||||
VISION_SYSTEM_PROMPT,
|
||||
VISION_USER_INSTRUCTION,
|
||||
} from "./prompts";
|
||||
|
|
@ -23,12 +27,14 @@ import {
|
|||
extractedNotesSchema,
|
||||
generatedActivitySchema,
|
||||
gradeResultSchema,
|
||||
homeworkCheckSchema,
|
||||
homeworkMakeSchema,
|
||||
homeworkStepSchema,
|
||||
type Difficulty,
|
||||
type ExtractedNotes,
|
||||
type GeneratedActivity,
|
||||
type GradeResult,
|
||||
type HomeworkCheck,
|
||||
type HomeworkGuide,
|
||||
type HomeworkMake,
|
||||
} from "./schemas";
|
||||
|
|
@ -502,6 +508,74 @@ export async function buildHomeworkGuide(input: {
|
|||
return value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Check my work */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HOMEWORK_CHECK_SHAPE_HINT = `{"title":"...","language":"English name of the language used","verdict":"1-2 sentences","correct":["what is right"],"wrong":[{"issue":"...","why":"...","fix":"..."}],"corrected":"the student's solution with every mistake fixed"}`;
|
||||
|
||||
/** Mode 3: mark the student's own attempt and hand back a corrected version. */
|
||||
export async function checkHomework(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
assignment: string;
|
||||
solution: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<HomeworkCheck> {
|
||||
const { value } = await runJsonStep<HomeworkCheck>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: HOMEWORK_CHECK_SYSTEM_PROMPT,
|
||||
user: buildHomeworkCheckUserPrompt({
|
||||
assignment: input.assignment,
|
||||
solution: input.solution,
|
||||
language: input.language,
|
||||
}),
|
||||
validate: (parsed) => {
|
||||
const result = homeworkCheckSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
if (!result.data.corrected.trim()) return { ok: false, error: "The corrected field is empty." };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: HOMEWORK_CHECK_SHAPE_HINT,
|
||||
label: "Checking the attempt",
|
||||
signal: input.signal,
|
||||
temperature: 0.2,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Follow-ups: rewrite one finished piece (shorter / more detail / explain). */
|
||||
export async function refineHomeworkText(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
instruction: "shorter" | "more" | "explain";
|
||||
text: string;
|
||||
context?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const result = await ollamaChat({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
json: false,
|
||||
signal: input.signal,
|
||||
temperature: 0.4,
|
||||
messages: [
|
||||
{ role: "system", content: REFINE_SYSTEM_PROMPT },
|
||||
{
|
||||
role: "user",
|
||||
content: buildRefineUserPrompt({
|
||||
instruction: input.instruction,
|
||||
text: input.text,
|
||||
context: input.context,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
return result.content.trim();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -154,6 +154,8 @@ export type HomeworkInput = {
|
|||
mode: HomeworkMode;
|
||||
images?: string[];
|
||||
text?: string;
|
||||
/** Mode "check": the student's own attempt at the assignment. */
|
||||
solution?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
|
|
@ -164,7 +166,17 @@ export type HomeworkProgress = {
|
|||
|
||||
export type StreamedHomework =
|
||||
| { mode: "make"; title: string; language?: string; answer: string; sourceText?: string }
|
||||
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string };
|
||||
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string }
|
||||
| {
|
||||
mode: "check";
|
||||
title: string;
|
||||
language?: string;
|
||||
verdict: string;
|
||||
correct: string[];
|
||||
wrong: Array<{ issue: string; why: string; fix: string }>;
|
||||
corrected: string;
|
||||
sourceText?: string;
|
||||
};
|
||||
|
||||
type HomeworkStreamEvent =
|
||||
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
|
||||
|
|
@ -224,6 +236,10 @@ export async function generateHomework(
|
|||
const result = homework as StreamedHomework;
|
||||
if (result.mode === "make") {
|
||||
if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
} else if (result.mode === "check") {
|
||||
if (!result.corrected?.trim() || !result.verdict?.trim()) {
|
||||
throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
}
|
||||
} else {
|
||||
const steps = result.steps?.filter((step) => step && typeof step.title === "string");
|
||||
if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
|
|
@ -242,6 +258,35 @@ export async function generateHomework(
|
|||
return { homework: result, meta };
|
||||
}
|
||||
|
||||
/** Follow-up on a finished result: shorter, more detail, or explain again. */
|
||||
export async function refineHomework(input: {
|
||||
instruction: "shorter" | "more" | "explain";
|
||||
text: string;
|
||||
context?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/homework/refine", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify({ instruction: input.instruction, text: input.text, context: input.context }),
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
||||
}
|
||||
if (!body?.text?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
return body.text.trim();
|
||||
}
|
||||
|
||||
export async function gradeAnswer(input: {
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type ServerConfig = {
|
|||
grade: number;
|
||||
homework: number;
|
||||
health: number;
|
||||
refine: number;
|
||||
sync: number;
|
||||
push: number;
|
||||
};
|
||||
|
|
@ -118,6 +119,8 @@ export function getServerConfig(): ServerConfig {
|
|||
// Homework is one to two model calls, like a deck.
|
||||
homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12),
|
||||
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
||||
// Homework follow-ups (short rewrites) can be used more freely.
|
||||
refine: positiveInt(process.env.RATE_LIMIT_REFINE, 30),
|
||||
// Sync is cheap (no model calls) but shouldn't be hammered.
|
||||
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
|
||||
// Push subscription changes and test sends.
|
||||
|
|
|
|||
36
lib/deck-seed.ts
Normal file
36
lib/deck-seed.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Hand-off from a homework result to the New deck flow: the content travels in
|
||||
* sessionStorage and the wizard picks it up on mount, with the options step
|
||||
* pre-filled. One-shot, like the share-target payload.
|
||||
*/
|
||||
|
||||
export type DeckSeed = {
|
||||
text: string;
|
||||
difficulty?: "easy" | "medium" | "hard";
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const KEY = "freezmo.deck.seed.v1";
|
||||
|
||||
export function setDeckSeed(seed: DeckSeed): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(KEY, JSON.stringify(seed));
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function takeDeckSeed(): DeckSeed | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
window.sessionStorage.removeItem(KEY);
|
||||
const parsed = JSON.parse(raw) as DeckSeed;
|
||||
if (typeof parsed.text !== "string" || !parsed.text.trim()) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +117,18 @@ export function getHomework(id: string): HomeworkRecord | undefined {
|
|||
/** Everything needed to store a result (the route's payload plus the source). */
|
||||
export type NewHomeworkResult =
|
||||
| { mode: "make"; title: string; language?: string; sourceText: string; answer: string }
|
||||
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] };
|
||||
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] }
|
||||
| {
|
||||
mode: "check";
|
||||
title: string;
|
||||
language?: string;
|
||||
sourceText: string;
|
||||
verdict: string;
|
||||
correct: string[];
|
||||
wrong: Array<{ issue: string; why: string; fix: string }>;
|
||||
corrected: string;
|
||||
attempt?: string;
|
||||
};
|
||||
|
||||
function buildRecord(
|
||||
base: { id: string; createdAt: number; updatedAt: number },
|
||||
|
|
@ -130,6 +141,17 @@ function buildRecord(
|
|||
sourceText: input.sourceText.slice(0, MAX_STORED_SOURCE),
|
||||
};
|
||||
if (input.mode === "make") return { ...shared, mode: "make", answer: input.answer };
|
||||
if (input.mode === "check") {
|
||||
return {
|
||||
...shared,
|
||||
mode: "check",
|
||||
verdict: input.verdict,
|
||||
correct: input.correct,
|
||||
wrong: input.wrong,
|
||||
corrected: input.corrected,
|
||||
attempt: input.attempt?.slice(0, 20_000),
|
||||
};
|
||||
}
|
||||
return { ...shared, mode: "guide", steps: input.steps };
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +189,9 @@ export function assembleSolution(record: HomeworkGuideRecord): string {
|
|||
|
||||
/** Label for a row in the history list. */
|
||||
export function homeworkModeLabelKey(mode: HomeworkMode): string {
|
||||
return mode === "make" ? "homework.history.made" : "homework.history.guide";
|
||||
if (mode === "make") return "homework.history.made";
|
||||
if (mode === "check") return "homework.history.checked";
|
||||
return "homework.history.guide";
|
||||
}
|
||||
|
||||
export type { HomeworkGuideRecord };
|
||||
|
|
|
|||
|
|
@ -856,6 +856,33 @@ const en: Record<string, string> = {
|
|||
"settings.reminderDenied": "Notifications are blocked in your browser settings.",
|
||||
"settings.reminderError": "Could not set up reminders.",
|
||||
"library.learningToday": "Today: {n} / {goal}",
|
||||
|
||||
/* homework: check my work + follow-ups ----------------------------- */
|
||||
"homework.mode.check": "Check my work",
|
||||
"homework.mode.checkDesc": "Mark your own attempt: what's right, what's wrong, and the corrected version.",
|
||||
"homework.solution.label": "Your solution",
|
||||
"homework.solution.placeholder": "Paste your own attempt here — the assignment is above.",
|
||||
"homework.solution.note": "Type or paste your solution as text (photos of handwritten work aren't supported yet).",
|
||||
"homework.solution.required": "Add your solution so there is something to check.",
|
||||
"homework.check.title": "Checked",
|
||||
"homework.check.verdict": "Verdict",
|
||||
"homework.check.correct": "What's right",
|
||||
"homework.check.wrong": "What needs fixing",
|
||||
"homework.check.fix": "Fix",
|
||||
"homework.check.corrected": "Corrected version",
|
||||
"homework.history.checked": "Checked",
|
||||
"homework.loading.checking": "Checking your attempt…",
|
||||
"homework.followup.shorter": "Shorter",
|
||||
"homework.followup.more": "More detail",
|
||||
"homework.followup.explain": "Explain differently",
|
||||
"homework.followup.redo": "Redo",
|
||||
"homework.followup.step": "Explain step {n} differently",
|
||||
"homework.followup.working": "Rewriting…",
|
||||
"homework.followup.failed": "That didn't work. Try again.",
|
||||
"homework.followup.restore": "Original",
|
||||
"homework.toDeck": "Turn into a study deck",
|
||||
"homework.toDeckNote": "Opens New deck with this content and the options pre-filled.",
|
||||
"server.needSolution": "Add your own solution so it can be checked.",
|
||||
"doc.readingPage": "Reading page {n} of {total}…",
|
||||
"doc.readingWord": "Reading the Word document…",
|
||||
"doc.readingSlides": "Reading the slides…",
|
||||
|
|
@ -1658,6 +1685,33 @@ const cs: Record<string, string> = {
|
|||
"settings.reminderDenied": "Oznámení jsou v nastavení prohlížeče zablokovaná.",
|
||||
"settings.reminderError": "Připomínky se nepodařilo nastavit.",
|
||||
"library.learningToday": "Dnes: {n} / {goal}",
|
||||
|
||||
/* homework: check my work + follow-ups ----------------------------- */
|
||||
"homework.mode.check": "Zkontrolovat moje řešení",
|
||||
"homework.mode.checkDesc": "Oznámkuj svůj postup: co je správně, co špatně a opravená verze.",
|
||||
"homework.solution.label": "Tvoje řešení",
|
||||
"homework.solution.placeholder": "Sem vlož svůj postup — zadání je nahoře.",
|
||||
"homework.solution.note": "Řešení napiš nebo vlož jako text (fotky ručně psaného postupu zatím nejdou).",
|
||||
"homework.solution.required": "Přidej svoje řešení, ať je co zkontrolovat.",
|
||||
"homework.check.title": "Zkontrolováno",
|
||||
"homework.check.verdict": "Hodnocení",
|
||||
"homework.check.correct": "Co je správně",
|
||||
"homework.check.wrong": "Co opravit",
|
||||
"homework.check.fix": "Oprava",
|
||||
"homework.check.corrected": "Opravená verze",
|
||||
"homework.history.checked": "Zkontrolováno",
|
||||
"homework.loading.checking": "Kontroluji tvůj postup…",
|
||||
"homework.followup.shorter": "Kratší",
|
||||
"homework.followup.more": "Více detailů",
|
||||
"homework.followup.explain": "Vysvětlit jinak",
|
||||
"homework.followup.redo": "Znovu",
|
||||
"homework.followup.step": "Vysvětlit krok {n} jinak",
|
||||
"homework.followup.working": "Přepisuji…",
|
||||
"homework.followup.failed": "Nepovedlo se. Zkus to znovu.",
|
||||
"homework.followup.restore": "Původní",
|
||||
"homework.toDeck": "Převést na studijní sadu",
|
||||
"homework.toDeckNote": "Otevře Novou sadu s tímto obsahem a předvyplněnými možnostmi.",
|
||||
"server.needSolution": "Přidej svoje řešení, ať se dá zkontrolovat.",
|
||||
"doc.readingPage": "Čtu stranu {n} z {total}…",
|
||||
"doc.readingWord": "Čtu dokument Word…",
|
||||
"doc.readingSlides": "Čtu snímky…",
|
||||
|
|
|
|||
|
|
@ -287,6 +287,86 @@ export function buildHomeworkGuideUserPrompt(input: { assignment: string; langua
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
export const HOMEWORK_CHECK_SYSTEM_PROMPT = `You are Freezmo's homework checker. The student gives you an assignment and their OWN attempt at solving it. You mark it like a fair, encouraging teacher: say what is right, what is wrong and why, and give the corrected version.
|
||||
|
||||
${HOMEWORK_BASE_RULES}
|
||||
|
||||
Checking rules:
|
||||
- Judge ONLY against the assignment. An unasked-for extra (a date, a comment, an alternative method) is never an error — leave it alone.
|
||||
- "verdict": 1-2 sentences, the overall picture (e.g. "Two of the three equations are right; the sign flips in the second one.").
|
||||
- "correct": short bullet points for what is genuinely right, each naming the part of the assignment it refers to. Empty array when nothing is right.
|
||||
- "wrong": one entry per actual mistake: "issue" names the mistake briefly, "why" explains what goes wrong, "fix" says what to do instead. Do not repeat the same mistake twice.
|
||||
- "corrected": the student's solution with every mistake fixed, as the finished homework (plain text, numbered like the assignment). Keep everything the student got right as they wrote it.
|
||||
- If the attempt is empty or does not address the assignment at all, say that plainly in the verdict, leave "wrong" empty and put the full solution in "corrected".
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short title of the assignment, in the assignment's language, max 10 words",
|
||||
"language": "English name of the language you wrote in, e.g. Czech",
|
||||
"verdict": "1-2 sentences",
|
||||
"correct": ["what is right"],
|
||||
"wrong": [{ "issue": "the mistake", "why": "why it is wrong", "fix": "what to do instead" }],
|
||||
"corrected": "the student's solution with every mistake fixed"
|
||||
}`;
|
||||
|
||||
export function buildHomeworkCheckUserPrompt(input: {
|
||||
assignment: string;
|
||||
solution: string;
|
||||
language?: string;
|
||||
}): string {
|
||||
return [
|
||||
"Check this attempt against the assignment.",
|
||||
input.language
|
||||
? `LANGUAGE: write everything in ${input.language}. Do not translate into English.`
|
||||
: "LANGUAGE: detect the language of the assignment below and write everything in that same language.",
|
||||
"",
|
||||
"=== THE ASSIGNMENT (the only source of what has to be done) ===",
|
||||
input.assignment.slice(0, 32_000),
|
||||
"=== END OF ASSIGNMENT ===",
|
||||
"",
|
||||
"=== THE STUDENT'S ATTEMPT (this is what you are marking) ===",
|
||||
input.solution.slice(0, 32_000),
|
||||
"=== END OF ATTEMPT ===",
|
||||
"",
|
||||
"Respond with the JSON object only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const REFINE_SYSTEM_PROMPT = `You are Freezmo's homework assistant. You rewrite one piece of already-finished homework exactly as asked — shorter, more detailed, or a step explained differently.
|
||||
|
||||
Rules:
|
||||
- Keep the language of the text you are given. Never translate.
|
||||
- Keep every fact, number and answer correct and consistent with the text; never invent new content that changes the answer.
|
||||
- Plain text only (short lines, numbered or dashed lists; no markdown symbols).
|
||||
- Output the rewritten text only: no greeting, no commentary, no "here is", no mention of these instructions.`;
|
||||
|
||||
export function buildRefineUserPrompt(input: {
|
||||
instruction: "shorter" | "more" | "explain";
|
||||
text: string;
|
||||
context?: string;
|
||||
}): string {
|
||||
const task =
|
||||
input.instruction === "shorter"
|
||||
? "Rewrite it SHORTER: keep every answer but cut the wording to the essentials."
|
||||
: input.instruction === "more"
|
||||
? "Rewrite it with MORE DETAIL: keep the same answers but explain the reasoning a little more, so the student understands why."
|
||||
: "Explain this step DIFFERENTLY: a fresh, simpler explanation of the same step, with a small concrete example, so it clicks this time.";
|
||||
return [
|
||||
task,
|
||||
input.context ? `CONTEXT (the assignment): ${input.context.slice(0, 2000)}` : "",
|
||||
"",
|
||||
"=== THE TEXT ===",
|
||||
input.text.slice(0, 16_000),
|
||||
"=== END OF TEXT ===",
|
||||
"",
|
||||
"Reply with the rewritten text only.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -139,9 +139,27 @@ export const gradeResultSchema = z.object({
|
|||
/* Make my homework */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const homeworkModeSchema = z.enum(["make", "guide"]);
|
||||
export const homeworkModeSchema = z.enum(["make", "guide", "check"]);
|
||||
export type HomeworkMode = z.infer<typeof homeworkModeSchema>;
|
||||
|
||||
export const checkIssueSchema = z.object({
|
||||
issue: z.string().min(1).max(600),
|
||||
why: z.string().min(1).max(600),
|
||||
fix: z.string().max(600).default(""),
|
||||
});
|
||||
export type CheckIssue = z.infer<typeof checkIssueSchema>;
|
||||
|
||||
/** Mode 3: feedback on the student's own solution. */
|
||||
export const homeworkCheckSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
language: z.string().max(60).optional(),
|
||||
verdict: z.string().min(1).max(600),
|
||||
correct: z.array(z.string().max(600)).max(12).default([]),
|
||||
wrong: z.array(checkIssueSchema).max(12).default([]),
|
||||
corrected: z.string().min(1).max(20_000),
|
||||
});
|
||||
export type HomeworkCheck = z.infer<typeof homeworkCheckSchema>;
|
||||
|
||||
/** Mode 1 — the finished homework, nothing else. */
|
||||
export const homeworkMakeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
|
|
@ -219,10 +237,21 @@ export const homeworkRecordSchema = z.discriminatedUnion("mode", [
|
|||
mode: z.literal("guide"),
|
||||
steps: z.array(homeworkStepStoredSchema).min(2).max(10),
|
||||
}),
|
||||
z.object({
|
||||
...homeworkBaseFields,
|
||||
mode: z.literal("check"),
|
||||
verdict: z.string().min(1).max(600),
|
||||
correct: z.array(z.string().max(600)).max(12),
|
||||
wrong: z.array(checkIssueSchema).max(12),
|
||||
corrected: z.string().min(1).max(20_000),
|
||||
/** The student's own attempt, so "Redo" can re-check the same work. */
|
||||
attempt: z.string().max(20_000).optional(),
|
||||
}),
|
||||
]);
|
||||
export type HomeworkRecord = z.infer<typeof homeworkRecordSchema>;
|
||||
export type HomeworkMakeRecord = Extract<HomeworkRecord, { mode: "make" }>;
|
||||
export type HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>;
|
||||
export type HomeworkCheckRecord = Extract<HomeworkRecord, { mode: "check" }>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client-side deck types (activity + id/xp, plus storage metadata) */
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ export async function shareHomework(record: HomeworkRecord): Promise<void> {
|
|||
const text =
|
||||
record.mode === "make"
|
||||
? record.answer
|
||||
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
|
||||
: record.mode === "check"
|
||||
? record.corrected
|
||||
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
|
||||
try {
|
||||
if (nav.share) {
|
||||
await nav.share({ title: record.title, text });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v26";
|
||||
const CACHE_VERSION = "freezmo-v27";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue