Fix ordering giveaways, the stuck false label, add Regenerate deck
Ordering cards - Generation prompt now forbids dates, years, times, step numbers and ordinals in ordering items (spec + hard rules), and the server-side validation drops any ordering card whose items still contain them instead of shipping a giveaway. True/False - The 'false ✕' swipe label was permanently visible on the card: its opacity curve was inverted, so the clamp at rest was 1. Flipped the range (and tightened both ranges for the elastic drag) — each label now only fades in while swiping in that direction. Regenerate deck - Results screen gains a Regenerate deck button: rebuilds the activities from the deck's stored source notes, replaces the deck in storage (attempts/best score reset) and starts the fresh run from slide 1. - StudyFeed tracks the current deck so the header, export and messages use the regenerated title/activities. - Fixed a spurious 0% attempt recorded when a regenerated deck swapped in: scroll anchoring held the old results position, so the feed briefly reported 5/5 with zero answers. The scroll now resets before the new slides render, overflow-anchor is disabled on the feed, and a run with no answers is never recorded. Tests: new deck-fixes suite 15/15 (TF overlay at rest/drag, regenerate with loading state + storage reset, ordering guard drop/keep); retry 14/14, deck chrome 15/15, pwa 16/16, final-ux 12/12, security 8/8, contrast 20/20.
This commit is contained in:
parent
1bacf43ed2
commit
33967f9454
6 changed files with 122 additions and 16 deletions
|
|
@ -71,6 +71,9 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
- Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices
|
- Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices
|
||||||
- Progress bar, activity counter, XP counter and answer streak in the chrome
|
- Progress bar, activity counter, XP counter and answer streak in the chrome
|
||||||
- Instant feedback with a short explanation for every activity, confetti when you finish
|
- Instant feedback with a short explanation for every activity, confetti when you finish
|
||||||
|
- Results screen: **Retry missed ones** (fresh, answerable items), **Study the whole deck again**,
|
||||||
|
and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling
|
||||||
|
until you're ready
|
||||||
|
|
||||||
**Activity types** (each one is its own React component, rendered from JSON by a `type` field)
|
**Activity types** (each one is its own React component, rendered from JSON by a `type` field)
|
||||||
|
|
||||||
|
|
@ -98,7 +101,8 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
- **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen.
|
- **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen.
|
||||||
New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**.
|
New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**.
|
||||||
Rename, delete (decks survive — they return to Unorganized) and move decks between folders
|
Rename, delete (decks survive — they return to Unorganized) and move decks between folders
|
||||||
- Rename, delete (with confirm), regenerate, and **add more activities** to an existing deck
|
- Rename, delete (with confirm), regenerate (from the library or the results screen), and
|
||||||
|
**add more activities** to an existing deck
|
||||||
- Export a deck as JSON and import it back — the folder name travels with the file and is recreated
|
- Export a deck as JSON and import it back — the folder name travels with the file and is recreated
|
||||||
on import
|
on import
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { motion } from "framer-motion";
|
||||||
import type { Activity, Deck } from "@/lib/schemas";
|
import type { Activity, Deck } from "@/lib/schemas";
|
||||||
import { downloadDeck } from "@/lib/storage";
|
import { downloadDeck } from "@/lib/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon } from "@/components/icons";
|
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
|
||||||
import { Button, Chip } from "@/components/ui";
|
import { Button, Chip } from "@/components/ui";
|
||||||
|
|
||||||
import type { AttemptResult } from "./StudyFeed";
|
import type { AttemptResult } from "./StudyFeed";
|
||||||
|
|
@ -60,6 +60,9 @@ export function ResultsScreen({
|
||||||
retryMode,
|
retryMode,
|
||||||
onRetryMissed,
|
onRetryMissed,
|
||||||
onRestart,
|
onRestart,
|
||||||
|
onRegenerate,
|
||||||
|
regenerating,
|
||||||
|
regenerateError,
|
||||||
}: {
|
}: {
|
||||||
deck: Deck;
|
deck: Deck;
|
||||||
xp: number;
|
xp: number;
|
||||||
|
|
@ -71,6 +74,9 @@ export function ResultsScreen({
|
||||||
retryMode: boolean;
|
retryMode: boolean;
|
||||||
onRetryMissed: () => void;
|
onRetryMissed: () => void;
|
||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
regenerating: boolean;
|
||||||
|
regenerateError: string | null;
|
||||||
}) {
|
}) {
|
||||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||||
const perfect = missed.length === 0 && total > 0;
|
const perfect = missed.length === 0 && total > 0;
|
||||||
|
|
@ -151,6 +157,21 @@ export function ResultsScreen({
|
||||||
<RefreshIcon className="size-5" /> Study the whole deck again
|
<RefreshIcon className="size-5" /> Study the whole deck again
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onRegenerate}
|
||||||
|
loading={regenerating}
|
||||||
|
disabled={regenerating}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-5" />
|
||||||
|
{regenerating ? "Regenerating…" : "Regenerate deck"}
|
||||||
|
</Button>
|
||||||
|
{regenerateError ? (
|
||||||
|
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
|
||||||
|
{regenerateError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
||||||
<DownloadIcon className="size-4" /> Export JSON
|
<DownloadIcon className="size-4" /> Export JSON
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { ActivityRenderer } from "@/components/activities";
|
import { ActivityRenderer } from "@/components/activities";
|
||||||
import { celebrate } from "@/lib/confetti";
|
import { celebrate } from "@/lib/confetti";
|
||||||
|
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||||
|
import { materialiseActivities } from "@/lib/deck-ops";
|
||||||
import type { Activity, Deck } from "@/lib/schemas";
|
import type { Activity, Deck } from "@/lib/schemas";
|
||||||
import { recordAttempt } from "@/lib/storage";
|
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
|
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
|
||||||
import { Button, ProgressBar } from "@/components/ui";
|
import { Button, ProgressBar } from "@/components/ui";
|
||||||
|
|
@ -17,6 +19,7 @@ import { ResultsScreen } from "./ResultsScreen";
|
||||||
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
||||||
|
|
||||||
export function StudyFeed({ deck }: { deck: Deck }) {
|
export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
|
const [currentDeck, setCurrentDeck] = useState(deck);
|
||||||
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [results, setResults] = useState<Record<string, AttemptResult>>({});
|
const [results, setResults] = useState<Record<string, AttemptResult>>({});
|
||||||
|
|
@ -24,6 +27,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
const [streak, setStreak] = useState(0);
|
const [streak, setStreak] = useState(0);
|
||||||
const [bestStreak, setBestStreak] = useState(0);
|
const [bestStreak, setBestStreak] = useState(0);
|
||||||
const [retryMode, setRetryMode] = useState(false);
|
const [retryMode, setRetryMode] = useState(false);
|
||||||
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
const [regenerateError, setRegenerateError] = useState<string | null>(null);
|
||||||
/** Bumped on every restart so the activity components remount fresh. */
|
/** Bumped on every restart so the activity components remount fresh. */
|
||||||
const [runId, setRunId] = useState(0);
|
const [runId, setRunId] = useState(0);
|
||||||
|
|
||||||
|
|
@ -95,10 +100,15 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
const answered = Object.values(results);
|
const answered = Object.values(results);
|
||||||
const correct = answered.filter((entry) => entry.correct).length;
|
const correct = answered.filter((entry) => entry.correct).length;
|
||||||
const score = total > 0 ? (correct / total) * 100 : 0;
|
const score = total > 0 ? (correct / total) * 100 : 0;
|
||||||
recordAttempt(deck.id, { scorePercent: score, correct, total });
|
// Only a run with at least one answer counts. Without this, the scroll
|
||||||
|
// anchoring shift when a regenerated deck swaps in could fire a bogus
|
||||||
|
// 0% attempt before the feed had snapped back to the first slide.
|
||||||
|
if (answered.length > 0) {
|
||||||
|
recordAttempt(currentDeck.id, { scorePercent: score, correct, total });
|
||||||
void celebrate("big");
|
void celebrate("big");
|
||||||
}
|
}
|
||||||
}, [finished, results, total, deck.id, retryMode]);
|
}
|
||||||
|
}, [finished, results, total, currentDeck.id, retryMode]);
|
||||||
|
|
||||||
const handleAnswered = useCallback(
|
const handleAnswered = useCallback(
|
||||||
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
|
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
|
||||||
|
|
@ -136,7 +146,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
setStreak(0);
|
setStreak(0);
|
||||||
setRetryMode(retry);
|
setRetryMode(retry);
|
||||||
recordedRef.current = retry; // don't overwrite the best score with a retry run
|
recordedRef.current = retry; // don't overwrite the best score with a retry run
|
||||||
// A new run: remount every activity (fresh state) and jump to the top.
|
// A new run: remount every activity (fresh state) and jump to the top
|
||||||
|
// *before* the new slides render, so scroll anchoring can't hold the
|
||||||
|
// old results position.
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (container) container.scrollTop = 0;
|
||||||
setRunId((value) => value + 1);
|
setRunId((value) => value + 1);
|
||||||
setIndex(0);
|
setIndex(0);
|
||||||
},
|
},
|
||||||
|
|
@ -149,6 +163,45 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
if (container) container.scrollTop = 0;
|
if (container) container.scrollTop = 0;
|
||||||
}, [runId]);
|
}, [runId]);
|
||||||
|
|
||||||
|
/** Rebuild the deck from its stored source notes and start it over. */
|
||||||
|
const regenerate = useCallback(async () => {
|
||||||
|
if (regenerating) return;
|
||||||
|
const source = currentDeck.sourceText?.trim();
|
||||||
|
if (!source) {
|
||||||
|
setRegenerateError("This deck has no stored source notes, so it can't be regenerated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegenerating(true);
|
||||||
|
setRegenerateError(null);
|
||||||
|
try {
|
||||||
|
const { deck: generated } = await generateDeck({
|
||||||
|
text: source,
|
||||||
|
difficulty: currentDeck.difficulty,
|
||||||
|
language: currentDeck.language,
|
||||||
|
count: Math.max(8, currentDeck.activities.length),
|
||||||
|
});
|
||||||
|
const activities = materialiseActivities(generated.activities, currentDeck.difficulty);
|
||||||
|
const updated: Deck = {
|
||||||
|
...currentDeck,
|
||||||
|
title: generated.title || currentDeck.title,
|
||||||
|
subject: generated.subject ?? currentDeck.subject,
|
||||||
|
summary: generated.summary ?? currentDeck.summary,
|
||||||
|
language: generated.language ?? currentDeck.language,
|
||||||
|
activities,
|
||||||
|
attempts: 0,
|
||||||
|
bestScore: undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
upsertDeck(updated);
|
||||||
|
setCurrentDeck(updated);
|
||||||
|
restart(activities, false);
|
||||||
|
} catch (error) {
|
||||||
|
setRegenerateError(error instanceof ApiError ? error.message : "Could not regenerate the deck.");
|
||||||
|
} finally {
|
||||||
|
setRegenerating(false);
|
||||||
|
}
|
||||||
|
}, [currentDeck, regenerating, restart]);
|
||||||
|
|
||||||
const progressValue = Math.min(index + 1, slideCount);
|
const progressValue = Math.min(index + 1, slideCount);
|
||||||
const progressMax = slideCount;
|
const progressMax = slideCount;
|
||||||
|
|
||||||
|
|
@ -197,7 +250,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||||
<span className="min-w-0 truncate">{deck.title}</span>
|
<span className="min-w-0 truncate">{currentDeck.title}</span>
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{retryMode ? "Retry · " : ""}
|
{retryMode ? "Retry · " : ""}
|
||||||
{Math.min(index + 1, total)}/{total}
|
{Math.min(index + 1, total)}/{total}
|
||||||
|
|
@ -210,7 +263,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
data-testid="study-feed"
|
data-testid="study-feed"
|
||||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain"
|
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
|
||||||
>
|
>
|
||||||
{queue.map((activity, activityIndex) => (
|
{queue.map((activity, activityIndex) => (
|
||||||
<section
|
<section
|
||||||
|
|
@ -222,7 +275,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
<ActivityRenderer
|
<ActivityRenderer
|
||||||
activity={activity}
|
activity={activity}
|
||||||
active={index === activityIndex}
|
active={index === activityIndex}
|
||||||
language={deck.language}
|
language={currentDeck.language}
|
||||||
isLast={activityIndex === total - 1}
|
isLast={activityIndex === total - 1}
|
||||||
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
|
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
|
||||||
onNext={() => goTo(activityIndex + 1)}
|
onNext={() => goTo(activityIndex + 1)}
|
||||||
|
|
@ -237,7 +290,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||||
<ResultsScreen
|
<ResultsScreen
|
||||||
deck={deck}
|
deck={currentDeck}
|
||||||
xp={xp}
|
xp={xp}
|
||||||
bestStreak={bestStreak}
|
bestStreak={bestStreak}
|
||||||
total={total}
|
total={total}
|
||||||
|
|
@ -246,7 +299,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
results={results}
|
results={results}
|
||||||
retryMode={retryMode}
|
retryMode={retryMode}
|
||||||
onRetryMissed={() => restart(missed, true)}
|
onRetryMissed={() => restart(missed, true)}
|
||||||
onRestart={() => restart(deck.activities, false)}
|
onRestart={() => restart(currentDeck.activities, false)}
|
||||||
|
onRegenerate={regenerate}
|
||||||
|
regenerating={regenerating}
|
||||||
|
regenerateError={regenerateError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,13 @@ type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||||
const [result, setResult] = useState<boolean | null>(null);
|
const [result, setResult] = useState<boolean | null>(null);
|
||||||
const x = useMotionValue(0);
|
const x = useMotionValue(0);
|
||||||
const trueOpacity = useTransform(x, [30, 110], [0, 1]);
|
// The drag is elastic, so the card moves less than the pointer — these
|
||||||
const falseOpacity = useTransform(x, [-30, -110], [1, 0]);
|
// ranges make each label reach full strength on a normal swipe.
|
||||||
|
const trueOpacity = useTransform(x, [20, 70], [0, 1]);
|
||||||
|
// Left drags go negative: at rest (0) this must be invisible, only fading
|
||||||
|
// in as the card is dragged left. The old range was inverted, so the
|
||||||
|
// "false ✕" label sat on the card permanently.
|
||||||
|
const falseOpacity = useTransform(x, [-70, -20], [1, 0]);
|
||||||
|
|
||||||
const answer = (value: boolean) => {
|
const answer = (value: boolean) => {
|
||||||
if (result !== null) return;
|
if (result !== null) return;
|
||||||
|
|
|
||||||
18
lib/ai.ts
18
lib/ai.ts
|
|
@ -250,6 +250,13 @@ const VALID_TYPES = [
|
||||||
"short_answer",
|
"short_answer",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Giveaways that make an ordering card solvable without knowing the material:
|
||||||
|
* years, clock times, "first/next/finally", "step 2", "1.".
|
||||||
|
*/
|
||||||
|
const ORDERING_GIVEAWAY =
|
||||||
|
/\b(1[0-9]{3}|20[0-9]{2})\b|\b\d{1,2}[:.]\d{2}\b|\b(first|second|third|fourth|fifth|sixth|seventh|eighth|1st|2nd|3rd|4th|5th|6th|7th|8th)\b|\b(step|stage|phase)\s*\d+\b|^\s*\d+[.)]\s/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Be forgiving: validate each activity on its own and keep the good ones.
|
* Be forgiving: validate each activity on its own and keep the good ones.
|
||||||
* Only if too few survive does the step get retried.
|
* Only if too few survive does the step get retried.
|
||||||
|
|
@ -280,7 +287,16 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) {
|
||||||
rawActivities.slice(0, 24).forEach((item, index) => {
|
rawActivities.slice(0, 24).forEach((item, index) => {
|
||||||
const result = generatedActivitySchema.safeParse(item);
|
const result = generatedActivitySchema.safeParse(item);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
activities.push(sanitiseActivity(result.data));
|
const activity = sanitiseActivity(result.data);
|
||||||
|
// An ordering card with dates/ordinals on it gives the answer away —
|
||||||
|
// drop it rather than ship a card the learner can solve without knowing.
|
||||||
|
if (activity.type === "ordering" && activity.items.some((entry) => ORDERING_GIVEAWAY.test(entry))) {
|
||||||
|
rejected.push(
|
||||||
|
`#${index + 1} (ordering): items must not contain dates, years, times or ordinals — the order has to come from the content.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activities.push(activity);
|
||||||
} else {
|
} else {
|
||||||
const type = (item as { type?: unknown } | null)?.type;
|
const type = (item as { type?: unknown } | null)?.type;
|
||||||
rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`);
|
rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`);
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type
|
||||||
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
|
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
|
||||||
|
|
||||||
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
|
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
|
||||||
{ "type": "ordering", "topic": "short label", "instruction": "Put the stages of mitosis in order.", "items": ["Prophase", "Metaphase", "Anaphase", "Telophase"], "explanation": "..." }
|
Items must NEVER contain dates, years, clock times, step numbers or ordinals ("first", "step 2") —
|
||||||
|
the learner has to work out the order from what each item says, not read it off.
|
||||||
|
{ "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." }
|
||||||
|
|
||||||
7. short_answer - typed answer, graded by AI
|
7. short_answer - typed answer, graded by AI
|
||||||
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." }
|
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." }
|
||||||
|
|
@ -102,6 +104,8 @@ Hard rules:
|
||||||
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
|
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
|
||||||
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
|
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
|
||||||
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
|
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
|
||||||
|
- Ordering items must never reveal their position: no dates, years, times, step numbers or ordinals
|
||||||
|
("first", "next", "finally", "step 2"). Describe the event or stage itself.
|
||||||
- Keep questions short enough to read on a phone screen. No walls of text.
|
- Keep questions short enough to read on a phone screen. No walls of text.
|
||||||
- Never reference "the notes", "the text above" or "the student". Speak directly to the learner.
|
- Never reference "the notes", "the text above" or "the student". Speak directly to the learner.
|
||||||
- Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning.
|
- Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue