Study cards: from-your-notes, read aloud, timed runs
From your notes: the generator now stores a verbatim source quote with every activity (schema + prompt + a server-side clamp so a chatty model cannot drop a card), and the feedback panel shows the sentence that carries it with the quote highlighted green. Decks made before the change, and quotes the model trimmed, fall back to a local IDF-weighted match over the saved notes text (lib/notes-source.ts): nothing is shown unless at least two distinct terms land. The passage hides in exam mode with the other reveals. Read aloud: a Web Speech API speaker control on the text a learner reads (flashcard faces, questions, statements, sentences, instructions) via lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice matched to the deck's language, and no button at all where the browser has no speech synthesis. It used to sit inside the flashcard's flip button (nested buttons, tapping it flipped the card); it now lives next to the hint row. Timed runs: the feed chrome carries a timer chip (off by default) that gives each card 30 seconds; a card that runs out is recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a toast, and the feed moves straight on with a fresh budget. handleAnswered ignores a second result per card, so a timeout plus a manual answer cannot record twice. The generator's type spec now names what each type suits (language decks lean on flashcards/matching/fill blank, ordering needs a real sequence, short answer reasoning), so the deck-aware guidance covers all seven types. New activities-test suite (24 checks: quote path, local path, the green mark, the speech stub, the timed run on Playwright's fake clock), registered in run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs keys for every new string; no new dependencies. Version 0.6.1, SW v83.
This commit is contained in:
parent
786d6cf1ae
commit
796e2e9fe9
26 changed files with 1068 additions and 51 deletions
46
README.md
46
README.md
|
|
@ -172,6 +172,13 @@ no database, no data leaving your machine except the requests you send to Ollama
|
|||
| `ordering` | Drag with the mouse/finger, or use ▲▼ buttons, then check |
|
||||
| `short_answer` | Typed answer graded by the AI via `/api/grade`, with a self-check fallback |
|
||||
|
||||
Every card carries the extras that make it studyable: a **speaker** control (Web Speech API,
|
||||
the deck's language, on the text you are meant to read — flashcard faces, questions, statements,
|
||||
instructions), a **“From your notes”** passage after you answer (the generator stores a verbatim
|
||||
quote with each activity; decks made before that get a local, IDF-weighted sentence match over
|
||||
the saved notes) with the matching words highlighted green, and the feed chrome's **⏱ timed run**
|
||||
(off by default — 30 seconds a card, running out counts as a miss and the feed moves on).
|
||||
|
||||
**First run**
|
||||
|
||||
- A full-screen **API-key gate** replaces the app until a key is saved (in `user-key` mode): one
|
||||
|
|
@ -846,11 +853,13 @@ app/
|
|||
components/
|
||||
activities/ one component per activity type + shared shell
|
||||
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
|
||||
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
|
||||
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx context.tsx
|
||||
NotesSource.tsx index.tsx
|
||||
homework/ make-my-homework screens (input, result, guide feed, history)
|
||||
chat/ chat screen, composer, message rows, markdown + math, pickers
|
||||
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
|
||||
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
|
||||
SpeakButton.tsx (read-aloud control for study cards)
|
||||
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
|
||||
lib/
|
||||
ollama.ts server-only HTTP client (timeouts, auth, error mapping)
|
||||
|
|
@ -871,6 +880,8 @@ lib/
|
|||
homework.ts localStorage homework results + solution assembly
|
||||
use-notes-intake.ts shared client-side photo/document intake hook
|
||||
clipboard.ts copy helper with a non-secure-context fallback
|
||||
notes-source.ts "from your notes": quote lookup + local sentence matching
|
||||
speech.ts Web Speech API wrapper (one utterance, deck language)
|
||||
image.ts client-side validation, resize and compression
|
||||
client-api.ts browser client for the API routes (NDJSON reader)
|
||||
chat.ts chat types + last-model memory
|
||||
|
|
@ -1423,6 +1434,39 @@ Things I chose while building, with the reasoning:
|
|||
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
|
||||
"cut off next to the sidebar".
|
||||
|
||||
92. **Study cards: where the answer came from, read aloud, and timed runs.** Three additions to
|
||||
the study feed, all local and dependency-free.
|
||||
- **From your notes.** The generator now returns `source` with every activity: the verbatim
|
||||
words from the notes that prove the answer (max 25 words; `clampQuote` in `lib/ai.ts` trims
|
||||
anything longer to a clean cut, so a chatty model cannot drop a card). After an answer the
|
||||
feedback panel shows the sentence that carries it with the quote highlighted green
|
||||
(`mark`). Decks made before the change — and quotes the model trimmed — fall back to a
|
||||
local, IDF-weighted match over the stored notes text (`lib/notes-source.ts`): a query from
|
||||
the activity's own wording, sentence scoring where rare words weigh more, word-boundary
|
||||
highlighting, and nothing shown unless at least two distinct terms land (a random sentence
|
||||
would be worse than silence). It disappears in exam mode with the other reveals.
|
||||
- **Read aloud.** A speaker control on the text a learner is meant to read (both flashcard
|
||||
faces, the visible one only; questions, statements, sentences, instructions) on the Web
|
||||
Speech API (`lib/speech.ts`, `components/SpeakButton.tsx`): one utterance at a time
|
||||
app-wide, a voice matched to the deck's language (BCP-47 table, interface locale as the
|
||||
fallback, 0.95 rate), and no button at all where the browser has no speech synthesis.
|
||||
Tapping the speaker used to flip the flashcard — it sat inside the flip button (nested
|
||||
buttons) — so it now lives next to the hint row, outside the flip target.
|
||||
- **Timed runs.** The feed chrome carries a timer chip (off by default): on, each card gets
|
||||
30 seconds (`TIMED_CARD_SECONDS`), the chip counts down, and a card that runs out is
|
||||
recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a
|
||||
toast, and the feed moves straight on to the next card with a fresh budget. The auto-advance
|
||||
is deliberately not deferred: recording the miss re-renders the feed and would cancel a
|
||||
queued scroll with its effect cleanup. `handleAnswered` now ignores a second result for the
|
||||
same card, so a timeout followed by a manual answer cannot write two history entries.
|
||||
- The generator's type spec now names what each type suits (flashcards, matching and fill in
|
||||
the blank for language decks; ordering for real sequences; short answer for reasoning), so
|
||||
the deck-aware guidance covers all seven types, not just ordering.
|
||||
New suite `activities-test` (24 checks: quote path, local path, the green mark, the speech
|
||||
stub — real `SpeechSynthesisUtterance`, recording `speak`/`cancel` — and the timed run with
|
||||
Playwright's fake clock), registered in run-all; the exam suite pins that the notes passage
|
||||
stays hidden with the other reveals; en + cs keys for every new string; no new dependencies.
|
||||
|
||||
91. **The chat chrome floats: one gutter, one radius scale, one glass recipe.** The header and
|
||||
the composer were full-bleed bands inside the main pane — flush to the viewport's top, right
|
||||
and bottom edges, with the composer's bottom corners cropped off-screen — while the rail
|
||||
|
|
|
|||
51
components/SpeakButton.tsx
Normal file
51
components/SpeakButton.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useSyncExternalStore } from "react";
|
||||
|
||||
import { SpeakerIcon, StopIcon } from "@/components/icons";
|
||||
import { useI18n, useLocale } from "@/lib/i18n";
|
||||
import { isSpeaking, speak, speechAvailable, speechLocale, stopSpeaking, subscribeSpeech } from "@/lib/speech";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Small round "read this aloud" control, placed next to the text a learner is
|
||||
* meant to read (a flashcard face, a question, a statement, an instruction).
|
||||
* It renders nothing where the browser has no speech synthesis, so no card
|
||||
* carries a dead button, and it flips to a stop control while it is the
|
||||
* utterance playing.
|
||||
*/
|
||||
export function SpeakButton({
|
||||
text,
|
||||
language,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
/** The deck's language, so the voice matches the text. */
|
||||
language?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const locale = useLocale();
|
||||
const id = useId();
|
||||
const speaking = useSyncExternalStore(
|
||||
subscribeSpeech,
|
||||
() => isSpeaking(id),
|
||||
() => false,
|
||||
);
|
||||
if (!speechAvailable()) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (speaking ? stopSpeaking() : speak(id, text, speechLocale(language, locale)))}
|
||||
aria-label={speaking ? t("activity.stopSpeaking") : t("activity.speak")}
|
||||
title={speaking ? t("activity.stopSpeaking") : t("activity.speak")}
|
||||
className={cn(
|
||||
"touch-target grid size-9 shrink-0 place-items-center rounded-full border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
speaking && "border-accent-400/70 text-accent-600 dark:text-accent-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{speaking ? <StopIcon className="size-4" /> : <SpeakerIcon className="size-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { ActivityRenderer, NotesTextProvider } from "@/components/activities";
|
||||
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
||||
import { celebrate } from "@/lib/confetti";
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
|
|
@ -17,7 +17,8 @@ import type { Activity, Deck } from "@/lib/schemas";
|
|||
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
||||
import { useWakeLock } from "@/lib/use-wake-lock";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { BoltIcon, ClockIcon, FlameIcon, XIcon } from "@/components/icons";
|
||||
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
|
|
@ -25,6 +26,9 @@ import { ResultsScreen } from "./ResultsScreen";
|
|||
|
||||
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
||||
|
||||
/** Seconds a card is given in a timed run (the chrome shows the countdown). */
|
||||
export const TIMED_CARD_SECONDS = 30;
|
||||
|
||||
export function StudyFeed({ deck }: { deck: Deck }) {
|
||||
const { t } = useI18n();
|
||||
/* Keep the screen awake for the whole study session (feature-detected). */
|
||||
|
|
@ -45,6 +49,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
const [regenerateError, setRegenerateError] = useState<string | null>(null);
|
||||
/** Bumped on every restart so the activity components remount fresh. */
|
||||
const [runId, setRunId] = useState(0);
|
||||
/** Timed run: each card gets TIMED_CARD_SECONDS, a card that runs out counts
|
||||
* as a miss. Off by default — the toggle sits in the chrome. */
|
||||
const [timed, setTimed] = useState(false);
|
||||
const [remaining, setRemaining] = useState<number | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const recordedRef = useRef(false);
|
||||
|
|
@ -135,8 +143,9 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
|
||||
const handleAnswered = useCallback(
|
||||
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
|
||||
if (results[activity.id]) return; // first answer counts, recorded once
|
||||
setResults((current) => {
|
||||
if (current[activity.id]) return current; // first answer counts
|
||||
if (current[activity.id]) return current;
|
||||
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
|
||||
});
|
||||
// Every answer feeds the history, the SRS schedule and the mistakes set.
|
||||
|
|
@ -160,9 +169,37 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
setStreak(0);
|
||||
}
|
||||
},
|
||||
[currentDeck.id, currentDeck.title, streak],
|
||||
[currentDeck.id, currentDeck.title, results, streak],
|
||||
);
|
||||
|
||||
/* Timed runs ---------------------------------------------------------- */
|
||||
const timedActivity = index < total ? queue[index] : null;
|
||||
const timedAnswered = timedActivity ? Boolean(results[timedActivity.id]) : true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!timed || !timedActivity || timedAnswered) {
|
||||
setRemaining(null);
|
||||
return;
|
||||
}
|
||||
setRemaining(TIMED_CARD_SECONDS);
|
||||
const timer = window.setInterval(() => {
|
||||
setRemaining((value) => (value === null ? null : Math.max(0, value - 1)));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [timed, timedActivity, timedAnswered, runId]);
|
||||
|
||||
/* Ran out: the card counts as a miss (the same path as a wrong answer, so
|
||||
history and the SRS schedule see it), the learner is told, and the feed
|
||||
moves on. */
|
||||
useEffect(() => {
|
||||
if (!timed || remaining !== 0 || !timedActivity || timedAnswered) return;
|
||||
handleAnswered(timedActivity, false, { userAnswer: t("feed.timedOut") });
|
||||
showToast({ message: t("feed.timedOut") });
|
||||
// Straight away, not on a timer: recording the miss re-renders (and
|
||||
// re-runs this effect), and a deferred scroll would be cleaned up with it.
|
||||
goTo(index + 1);
|
||||
}, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
|
||||
|
||||
const missed = useMemo(
|
||||
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
|
||||
[queue, results, skipped],
|
||||
|
|
@ -244,7 +281,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
const progressMax = slideCount;
|
||||
|
||||
return (
|
||||
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
|
||||
// The deck's own notes travel with the feed: after an answer the feedback
|
||||
// panel points back into them (NotesSource).
|
||||
<NotesTextProvider value={currentDeck.sourceText}>
|
||||
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
|
||||
{/* Chrome ---------------------------------------------------- */}
|
||||
{/* One pill: round close button, title + position above the bar, and
|
||||
the streak/xp chips at the other end. Everything sits on the pill's
|
||||
|
|
@ -279,6 +319,22 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-timed-toggle
|
||||
data-timed-seconds={TIMED_CARD_SECONDS}
|
||||
aria-pressed={timed}
|
||||
onClick={() => setTimed((value) => !value)}
|
||||
aria-label={timed ? t("feed.timedOn") : t("feed.timed")}
|
||||
title={timed ? t("feed.timedOn") : t("feed.timed")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
|
||||
timed ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
<ClockIcon className="size-3.5" />
|
||||
{timed && remaining !== null ? `${remaining}s` : `${TIMED_CARD_SECONDS}s`}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
|
||||
|
|
@ -410,6 +466,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NotesTextProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useI18n } from "@/lib/i18n";
|
|||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -67,7 +68,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
|||
});
|
||||
}
|
||||
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FillBlankActivity>) {
|
||||
export function FillBlank({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
|
|
@ -141,6 +142,9 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
|
|||
)}
|
||||
{after}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<SpeakButton text={activity.sentence} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
{activity.options ? (
|
||||
|
|
@ -181,6 +185,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
|
|||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useState } from "react";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -19,7 +20,7 @@ import {
|
|||
|
||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
|
||||
export function Flashcard({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
|
|
@ -46,7 +47,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
return (
|
||||
<>
|
||||
<ActivityHeader type="flashcard" topic={activity.topic} />
|
||||
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
|
||||
<SpeakButton text={flipped ? activity.back : activity.front} language={language} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -100,6 +104,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -16,7 +17,7 @@ import {
|
|||
|
||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
||||
export function Matching({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
||||
const { t, plural } = useI18n();
|
||||
const terms = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||
|
|
@ -67,7 +68,10 @@ export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
}
|
||||
/>
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<SpeakButton text={activity.instruction} language={language} />
|
||||
</div>
|
||||
<Hint>
|
||||
{done
|
||||
? t("matching.allMatched")
|
||||
|
|
@ -133,6 +137,7 @@ export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
|
||||
{complete ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={misses === 0}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -20,6 +21,7 @@ type MultipleChoiceActivity = Extract<Activity, { type: "multiple_choice" }>;
|
|||
export function MultipleChoice({
|
||||
activity,
|
||||
active,
|
||||
language,
|
||||
onAnswered,
|
||||
onNext,
|
||||
isLast,
|
||||
|
|
@ -62,7 +64,10 @@ export function MultipleChoice({
|
|||
<>
|
||||
<ActivityHeader type="multiple_choice" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<SpeakButton text={activity.question} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
<div className="flex flex-col gap-2.5" role="group" aria-label={t("activity.answerOptions")}>
|
||||
|
|
@ -83,6 +88,7 @@ export function MultipleChoice({
|
|||
|
||||
{answered ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={selected === activity.correctIndex}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
55
components/activities/NotesSource.tsx
Normal file
55
components/activities/NotesSource.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { NotesIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { highlightSegments, notesSource } from "@/lib/notes-source";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
|
||||
import { useNoHints, useNotesText } from "./context";
|
||||
|
||||
/**
|
||||
* "From your notes": the passage an activity's answer comes from, with the
|
||||
* matching words highlighted green.
|
||||
*
|
||||
* The quote is the one the generator stored with the activity (verbatim from
|
||||
* the notes); decks made before that, or quotes the model trimmed, fall back
|
||||
* to a local, IDF-weighted sentence match over the same notes text. It sits
|
||||
* inside the feedback panel, so it only appears once the learner has answered,
|
||||
* and it disappears in exam mode with the other reveals.
|
||||
*/
|
||||
export function NotesSource({ activity }: { activity: Activity }) {
|
||||
const { t } = useI18n();
|
||||
const notes = useNotesText();
|
||||
const noHints = useNoHints();
|
||||
const match = useMemo(() => (noHints ? null : notesSource(notes, activity)), [noHints, notes, activity]);
|
||||
if (!match) return null;
|
||||
const pieces = highlightSegments(match.text, match.spans);
|
||||
return (
|
||||
<div className="mt-3 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] p-3">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-wider text-[var(--text-muted)]">
|
||||
<NotesIcon className="size-3.5" />
|
||||
{t("activity.source")}
|
||||
</p>
|
||||
<p
|
||||
className="text-[13px] leading-relaxed text-[var(--text)]"
|
||||
data-notes-source
|
||||
data-notes-quote={match.quote ? "1" : "0"}
|
||||
>
|
||||
{pieces.map((piece, index) =>
|
||||
piece.hit ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="rounded bg-emerald-200/85 px-0.5 text-emerald-950 dark:bg-emerald-400/35 dark:text-emerald-50"
|
||||
>
|
||||
{piece.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{piece.text}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
|
||||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<OrderingActivity>) {
|
||||
export function Ordering({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<OrderingActivity>) {
|
||||
const { t } = useI18n();
|
||||
const initial = useMemo(() => {
|
||||
const out = shuffle(activity.items);
|
||||
|
|
@ -54,7 +55,10 @@ export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
<>
|
||||
<ActivityHeader type="ordering" topic={activity.topic} />
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<SpeakButton text={activity.instruction} language={language} />
|
||||
</div>
|
||||
<Hint>
|
||||
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
|
||||
</Hint>
|
||||
|
|
@ -128,6 +132,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
</Button>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { Activity } from "@/lib/schemas";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -82,7 +83,10 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
<>
|
||||
<ActivityHeader type="short_answer" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<SpeakButton text={activity.question} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
||||
|
|
@ -186,6 +190,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
</div>
|
||||
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={state.correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useState } from "react";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -17,7 +18,7 @@ import {
|
|||
|
||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
export function TrueFalse({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
|
|
@ -65,6 +66,9 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
||||
“{activity.statement}”
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<SpeakButton text={activity.statement} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -80,6 +84,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
</div>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
36
components/activities/context.tsx
Normal file
36
components/activities/context.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* The two ambient facts an activity card needs from the screen around it:
|
||||
*
|
||||
* - the deck's own notes text, so the feedback panel can show the passage an
|
||||
* answer came from (NotesSource). Absent on screens with no notes (the
|
||||
* homework guide's checkpoints);
|
||||
* - whether reveals are off (exam mode), which hides the hints *and* that
|
||||
* passage — both give the answer away.
|
||||
*
|
||||
* Its own module (not shared.tsx) because NotesSource needs these hooks while
|
||||
* shared.tsx renders NotesSource; a cycle there would be fragile.
|
||||
*/
|
||||
|
||||
const NotesTextContext = createContext<string | undefined>(undefined);
|
||||
|
||||
export function NotesTextProvider({ value, children }: { value?: string; children: React.ReactNode }) {
|
||||
return <NotesTextContext.Provider value={value}>{children}</NotesTextContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNotesText(): string | undefined {
|
||||
return useContext(NotesTextContext);
|
||||
}
|
||||
|
||||
const NoHintsContext = createContext(false);
|
||||
|
||||
export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) {
|
||||
return <NoHintsContext.Provider value={value}>{children}</NoHintsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoHints(): boolean {
|
||||
return useContext(NoHintsContext);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { NoHintsProvider, type ActivityComponentProps } from "./shared";
|
|||
|
||||
export type { ActivityComponentProps } from "./shared";
|
||||
export { TYPE_META } from "./shared";
|
||||
export { NotesTextProvider, useNotesText } from "./context";
|
||||
|
||||
/**
|
||||
* Renders an activity from JSON based on its `type` field.
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@
|
|||
|
||||
import { motion } from "framer-motion";
|
||||
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useNoHints } from "./context";
|
||||
import { NotesSource } from "./NotesSource";
|
||||
|
||||
export { NoHintsProvider, useNoHints, NotesTextProvider, useNotesText } from "./context";
|
||||
export { NotesSource } from "./NotesSource";
|
||||
|
||||
export type ActivityComponentProps<A extends Activity = Activity> = {
|
||||
activity: A;
|
||||
|
|
@ -150,6 +154,7 @@ export function FeedbackPanel({
|
|||
nextLabel,
|
||||
headline,
|
||||
detail,
|
||||
activity,
|
||||
children,
|
||||
}: {
|
||||
correct: boolean;
|
||||
|
|
@ -161,6 +166,8 @@ export function FeedbackPanel({
|
|||
nextLabel?: string;
|
||||
headline?: string;
|
||||
detail?: React.ReactNode;
|
||||
/** The card being answered, so the panel can point back into the notes. */
|
||||
activity?: Activity;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -210,6 +217,8 @@ export function FeedbackPanel({
|
|||
{explanation}
|
||||
</p>
|
||||
|
||||
{activity ? <NotesSource activity={activity} /> : null}
|
||||
|
||||
{children}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
|
|
@ -230,17 +239,5 @@ export function Hint({ children }: { children: React.ReactNode }) {
|
|||
return <p className="hint-line text-sm text-frost-600 dark:text-frost-300">{children}</p>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exam mode sets this through ActivityRenderer's `noHints` prop so the
|
||||
* affordances that give the answer away disappear without touching every
|
||||
* activity component.
|
||||
*/
|
||||
const NoHintsContext = createContext(false);
|
||||
|
||||
export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) {
|
||||
return <NoHintsContext.Provider value={value}>{children}</NoHintsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoHints(): boolean {
|
||||
return useContext(NoHintsContext);
|
||||
}
|
||||
/* The no-hints / notes-text contexts live in ./context: NotesSource reads them
|
||||
while this file renders NotesSource, so keeping them apart avoids a cycle. */
|
||||
|
|
|
|||
|
|
@ -399,3 +399,33 @@ export function ChromeIcon({ className }: IconProps) {
|
|||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-aloud control (study cards); the waves light up while speaking. */
|
||||
export function SpeakerIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4.5 9.4h2.9l4-3.2v11.6l-4-3.2H4.5z" />
|
||||
<path d="M15.2 9.2a3.9 3.9 0 0 1 0 5.6M17.7 6.7a7.3 7.3 0 0 1 0 10.6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Timed runs. */
|
||||
export function ClockIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="8.2" />
|
||||
<path d="M12 7.4V12l3.1 1.9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** "From your notes" — a page with a folded corner and text lines. */
|
||||
export function NotesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M6.4 3.6h7.4l4.8 4.8v12H6.4z" />
|
||||
<path d="M13.6 3.9v4.7h4.7M9.4 12.4h5.2M9.4 15.8h5.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
32
lib/ai.ts
32
lib/ai.ts
|
|
@ -630,24 +630,40 @@ export async function refineHomeworkText(input: {
|
|||
* deduping — a shorter list would fail validation on the next read and take
|
||||
* the whole deck down with it.
|
||||
*/
|
||||
/**
|
||||
* The "source" quote is shown verbatim (and matched against the stored notes),
|
||||
* so a model that ignores the 25-word rule gets clamped to a clean, short
|
||||
* quote: whitespace collapsed, cut at a sentence or word boundary.
|
||||
*/
|
||||
function clampQuote(quote: string): string {
|
||||
const clean = quote.replace(/\s+/g, " ").trim();
|
||||
if (clean.length <= 240) return clean;
|
||||
const cut = clean.slice(0, 240);
|
||||
const boundary = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
|
||||
if (boundary > 80) return cut.slice(0, boundary + 1);
|
||||
const space = cut.lastIndexOf(" ");
|
||||
return space > 40 ? cut.slice(0, space) : cut;
|
||||
}
|
||||
|
||||
function sanitiseActivity(activity: GeneratedActivity): GeneratedActivity {
|
||||
switch (activity.type) {
|
||||
const sanitised = activity.source ? { ...activity, source: clampQuote(activity.source) } : activity;
|
||||
switch (sanitised.type) {
|
||||
case "ordering": {
|
||||
const items = dedupeBy(activity.items, (item) => item);
|
||||
return items.length === activity.items.length || items.length < 3 ? activity : { ...activity, items };
|
||||
const items = dedupeBy(sanitised.items, (item) => item);
|
||||
return items.length === sanitised.items.length || items.length < 3 ? sanitised : { ...sanitised, items };
|
||||
}
|
||||
case "fill_blank": {
|
||||
if (/_{2,}/.test(activity.sentence)) return activity;
|
||||
return { ...activity, sentence: `${activity.sentence.trim()} ____` };
|
||||
if (/_{2,}/.test(sanitised.sentence)) return sanitised;
|
||||
return { ...sanitised, sentence: `${sanitised.sentence.trim()} ____` };
|
||||
}
|
||||
case "matching": {
|
||||
// Terms first, then definitions: one definition per term, or the learner
|
||||
// cannot tell the pairs apart.
|
||||
const pairs = dedupeBy(dedupeBy(activity.pairs, (pair) => pair.term), (pair) => pair.definition);
|
||||
return pairs.length === activity.pairs.length || pairs.length < 3 ? activity : { ...activity, pairs };
|
||||
const pairs = dedupeBy(dedupeBy(sanitised.pairs, (pair) => pair.term), (pair) => pair.definition);
|
||||
return pairs.length === sanitised.pairs.length || pairs.length < 3 ? sanitised : { ...sanitised, pairs };
|
||||
}
|
||||
default:
|
||||
return activity;
|
||||
return sanitised;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -535,6 +535,9 @@ const en: Record<string, string> = {
|
|||
"feed.results": "Results",
|
||||
"feed.report": "Report question",
|
||||
"feed.reported": "Question reported — it won't show up again.",
|
||||
"feed.timed": "Timed run · 30s per card",
|
||||
"feed.timedOn": "Timed run on — a card that runs out counts as a miss",
|
||||
"feed.timedOut": "Time's up — the card counts as a miss.",
|
||||
|
||||
/* results --------------------------------------------------------- */
|
||||
"results.perfect": "Perfect freeze! 🧊",
|
||||
|
|
@ -656,6 +659,9 @@ const en: Record<string, string> = {
|
|||
"activity.next": "Next",
|
||||
"activity.correctAnswer": "Correct answer:",
|
||||
"activity.unsupported": "Unsupported activity: {json}",
|
||||
"activity.source": "From your notes",
|
||||
"activity.speak": "Read aloud",
|
||||
"activity.stopSpeaking": "Stop reading",
|
||||
|
||||
/* flashcard ------------------------------------------------------- */
|
||||
"flashcard.tapToFlip": "Tap the card to flip it.",
|
||||
|
|
@ -1524,6 +1530,9 @@ const cs: Record<string, string> = {
|
|||
"feed.prev": "Předchozí aktivita",
|
||||
"feed.next": "Další aktivita",
|
||||
"feed.results": "Výsledky",
|
||||
"feed.timed": "Kolo na čas · 30 s na kartu",
|
||||
"feed.timedOn": "Na čas — kartě, které vyprší čas, se počítá jako chyba",
|
||||
"feed.timedOut": "Čas vypršel — karta se počítá jako chyba.",
|
||||
"feed.report": "Nahlásit otázku",
|
||||
"feed.reported": "Otázka nahlášena — už se neukáže.",
|
||||
|
||||
|
|
@ -1647,6 +1656,9 @@ const cs: Record<string, string> = {
|
|||
"activity.next": "Další",
|
||||
"activity.correctAnswer": "Správná odpověď:",
|
||||
"activity.unsupported": "Nepodporovaná aktivita: {json}",
|
||||
"activity.source": "Z tvých poznámek",
|
||||
"activity.speak": "Přečíst nahlas",
|
||||
"activity.stopSpeaking": "Zastavit čtení",
|
||||
|
||||
/* flashcard ------------------------------------------------------- */
|
||||
"flashcard.tapToFlip": "Klepnutím na kartu ji otočíš.",
|
||||
|
|
|
|||
309
lib/notes-source.ts
Normal file
309
lib/notes-source.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* "Where in my notes did this come from?" — the study feed shows the passage
|
||||
* that carries an activity's answer, with the matching words highlighted.
|
||||
*
|
||||
* Two paths, both local (nothing leaves the browser):
|
||||
* - activities generated after the source-quote change carry a verbatim
|
||||
* `source` quote; it is located in the stored notes and the sentence around
|
||||
* it is shown, with the quote itself highlighted;
|
||||
* - older activities have no quote, so the passage is found by scoring the
|
||||
* notes' sentences against the activity's own wording. Rarer words weigh
|
||||
* more (an IDF sum), so "Rayleigh" beats "the", and a sentence has to match
|
||||
* at least two distinct terms to count.
|
||||
*
|
||||
* Returns null when nothing convincing is found — a random sentence presented
|
||||
* as "where this came from" would be worse than showing nothing.
|
||||
*/
|
||||
|
||||
import type { Activity } from "./schemas";
|
||||
|
||||
export type NotesMatch = {
|
||||
/** The passage to show. */
|
||||
text: string;
|
||||
/** Highlight spans inside `text` (start, end). */
|
||||
spans: Array<[number, number]>;
|
||||
/** True when the AI supplied the quote (vs. a local sentence match). */
|
||||
quote: boolean;
|
||||
};
|
||||
|
||||
/** Longest excerpt shown under an activity. */
|
||||
const MAX_EXCERPT = 280;
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
"the", "and", "that", "with", "from", "this", "which", "are", "was", "were", "have", "has", "had",
|
||||
"for", "not", "but", "you", "your", "they", "their", "into", "than", "then", "what", "when",
|
||||
"where", "why", "how", "does", "did", "can", "could", "should", "would", "its", "one", "two",
|
||||
"about", "over", "under", "more", "most", "some", "any", "all", "each", "also", "such", "only",
|
||||
"very", "just", "like", "make", "made", "use", "used", "using", "how", "who", "whose", "will",
|
||||
]);
|
||||
|
||||
const isWordCharacter = (character: string | undefined) =>
|
||||
Boolean(character && /[\p{L}\p{N}]/u.test(character));
|
||||
|
||||
/** Lower-case the words of a text, for term comparison. */
|
||||
export function termsOf(text: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((word) => word.length >= 3 && !STOPWORDS.has(word)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity's own wording, used when no quote is stored: the question side
|
||||
* plus the answer side, so both "what was asked" and "what the answer mentions"
|
||||
* can hit a sentence in the notes.
|
||||
*/
|
||||
export function activityQuery(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return `${activity.front} ${activity.back}`;
|
||||
case "multiple_choice":
|
||||
return `${activity.question} ${activity.options[activity.correctIndex] ?? ""}`;
|
||||
case "true_false":
|
||||
return activity.statement;
|
||||
case "fill_blank":
|
||||
return `${activity.sentence.replace(/_{2,}/g, " ")} ${activity.answer}`;
|
||||
case "matching":
|
||||
return activity.pairs.map((pair) => `${pair.term} ${pair.definition}`).join(" ");
|
||||
case "ordering":
|
||||
return activity.items.join(" ");
|
||||
case "short_answer":
|
||||
return `${activity.question} ${activity.referenceAnswer} ${activity.keyPoints.join(" ")}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** `text` plus a per-character index map into the original. */
|
||||
function normalise(text: string): { text: string; map: number[] } {
|
||||
let out = "";
|
||||
const map: number[] = [];
|
||||
let pendingSpace = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (/\s/.test(character)) {
|
||||
pendingSpace = out.length > 0;
|
||||
continue;
|
||||
}
|
||||
if (pendingSpace) {
|
||||
out += " ";
|
||||
map.push(index);
|
||||
pendingSpace = false;
|
||||
}
|
||||
out += character.toLowerCase();
|
||||
map.push(index);
|
||||
}
|
||||
return { text: out, map };
|
||||
}
|
||||
|
||||
/** The quote (normalised) inside the notes, or the longest verbatim word run. */
|
||||
function locateQuote(notes: string, quote: string): [number, number] | null {
|
||||
const haystack = normalise(notes);
|
||||
const needle = normalise(quote).text.replace(/…/g, "").trim();
|
||||
if (needle.length < 8) return null;
|
||||
|
||||
const span = (from: number, to: number): [number, number] => [
|
||||
haystack.map[from],
|
||||
haystack.map[to - 1] + 1,
|
||||
];
|
||||
|
||||
const exact = haystack.text.indexOf(needle);
|
||||
if (exact >= 0) return span(exact, exact + needle.length);
|
||||
|
||||
// The model paraphrased or trimmed the quote: fall back to the longest run
|
||||
// of consecutive words that does appear verbatim.
|
||||
const words = needle.split(" ").filter(Boolean);
|
||||
for (let size = Math.min(words.length, 16); size >= 3; size -= 1) {
|
||||
for (let start = 0; start + size <= words.length; start += 1) {
|
||||
const chunk = words.slice(start, start + size).join(" ");
|
||||
const at = haystack.text.indexOf(chunk);
|
||||
if (at >= 0) return span(at, at + chunk.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Expand a match to the sentence (or line) around it. */
|
||||
function sentenceAround(text: string, start: number, end: number): [number, number] {
|
||||
let from = start;
|
||||
while (from > 0) {
|
||||
const previous = text[from - 1];
|
||||
if (previous === "\n" || previous === "." || previous === "!" || previous === "?") break;
|
||||
from -= 1;
|
||||
}
|
||||
let to = end;
|
||||
while (to < text.length) {
|
||||
const character = text[to];
|
||||
if (character === "\n" || character === "." || character === "!" || character === "?") {
|
||||
to += 1;
|
||||
break;
|
||||
}
|
||||
to += 1;
|
||||
}
|
||||
while (from < to && /\s/.test(text[from])) from += 1;
|
||||
while (to > from && /\s/.test(text[to - 1])) to -= 1;
|
||||
return [from, to];
|
||||
}
|
||||
|
||||
/** Trim a passage to `max` characters around the first highlight. */
|
||||
function clip(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
spans: Array<[number, number]>,
|
||||
max: number,
|
||||
): { text: string; spans: Array<[number, number]> } | null {
|
||||
const length = end - start;
|
||||
if (length <= max) {
|
||||
return {
|
||||
text: text.slice(start, end),
|
||||
spans: spans
|
||||
.map(([from, to]) => [Math.max(start, from), Math.min(end, to)] as [number, number])
|
||||
.filter(([from, to]) => to > from)
|
||||
.map(([from, to]) => [from - start, to - start] as [number, number]),
|
||||
};
|
||||
}
|
||||
const anchor = spans[0] ?? [start, start];
|
||||
const from = Math.max(start, Math.min(anchor[0] - Math.floor(max / 3), end - max));
|
||||
const to = Math.min(end, from + max);
|
||||
const prefix = from > start ? "… " : "";
|
||||
const suffix = to < end ? " …" : "";
|
||||
const trimmed = `${prefix}${text.slice(from, to).trim()}${suffix}`;
|
||||
const shift = from + prefix.length;
|
||||
return {
|
||||
text: trimmed,
|
||||
spans: spans
|
||||
.map(([spanFrom, spanTo]) => [Math.max(from, spanFrom) - shift, Math.min(to, spanTo) - shift] as [number, number])
|
||||
.filter(([spanFrom, spanTo]) => spanTo > spanFrom && spanFrom >= 0 && spanTo <= trimmed.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** Sentence split that keeps offsets; no lookbehind (older Safari). */
|
||||
function sentences(text: string): Array<{ start: number; end: number }> {
|
||||
const out: Array<{ start: number; end: number }> = [];
|
||||
let start = 0;
|
||||
const push = (end: number) => {
|
||||
let from = start;
|
||||
while (from < end && /\s/.test(text[from])) from += 1;
|
||||
let to = end;
|
||||
while (to > from && /\s/.test(text[to - 1])) to -= 1;
|
||||
if (to - from >= 8) out.push({ start: from, end: to });
|
||||
start = end;
|
||||
};
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (character === "\n") {
|
||||
push(index);
|
||||
start = index + 1;
|
||||
continue;
|
||||
}
|
||||
if ((character === "." || character === "!" || character === "?") && /\s/.test(text[index + 1] ?? "\n")) {
|
||||
push(index + 1);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
push(text.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every occurrence of the matched terms in a sentence (word boundaries). */
|
||||
function termSpans(text: string, matched: Set<string>): Array<[number, number]> {
|
||||
const { text: haystack, map } = normalise(text);
|
||||
const spans: Array<[number, number]> = [];
|
||||
for (const term of matched) {
|
||||
let at = haystack.indexOf(term);
|
||||
while (at >= 0) {
|
||||
if (!isWordCharacter(haystack[at - 1]) && !isWordCharacter(haystack[at + term.length])) {
|
||||
spans.push([map[at], map[at + term.length - 1] + 1]);
|
||||
}
|
||||
at = haystack.indexOf(term, at + 1);
|
||||
}
|
||||
}
|
||||
return spans.sort((a, b) => a[0] - b[0]);
|
||||
}
|
||||
|
||||
/** The best-matching sentence for a query, IDF-weighted. */
|
||||
export function bestSentence(notes: string, query: string): NotesMatch | null {
|
||||
const list = sentences(notes);
|
||||
if (list.length === 0) return null;
|
||||
const queryTerms = termsOf(query);
|
||||
if (queryTerms.length === 0) return null;
|
||||
|
||||
const perSentence = list.map((sentence) => new Set(termsOf(notes.slice(sentence.start, sentence.end))));
|
||||
const documentFrequency = new Map<string, number>();
|
||||
for (const unique of perSentence) {
|
||||
for (const term of unique) documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
|
||||
}
|
||||
const total = list.length;
|
||||
|
||||
let best: { index: number; score: number; matched: Set<string> } | null = null;
|
||||
perSentence.forEach((unique, index) => {
|
||||
const matched = new Set<string>();
|
||||
let score = 0;
|
||||
for (const term of queryTerms) {
|
||||
if (!unique.has(term)) continue;
|
||||
matched.add(term);
|
||||
score += Math.log(1 + total / (documentFrequency.get(term) ?? 1));
|
||||
}
|
||||
const enough = matched.size >= 2 || (matched.size === 1 && total <= 3);
|
||||
if (!enough) return;
|
||||
if (!best || score > best.score) best = { index, score, matched };
|
||||
});
|
||||
if (!best) return null;
|
||||
|
||||
const winner: { index: number; score: number; matched: Set<string> } = best;
|
||||
const sentence = list[winner.index];
|
||||
const spans = termSpans(notes.slice(sentence.start, sentence.end), winner.matched);
|
||||
const clipped = clip(notes, sentence.start, sentence.end, spans, MAX_EXCERPT);
|
||||
if (!clipped) return null;
|
||||
return { text: clipped.text, spans: clipped.spans, quote: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The passage that carries this activity's answer, from the AI's own quote
|
||||
* when there is one and by local matching otherwise.
|
||||
*/
|
||||
export function notesSource(notes: string | undefined, activity: Activity): NotesMatch | null {
|
||||
const text = (notes ?? "").trim();
|
||||
if (text.length < 24) return null;
|
||||
|
||||
if (activity.source) {
|
||||
const located = locateQuote(text, activity.source);
|
||||
if (located) {
|
||||
const [start, end] = sentenceAround(text, located[0], located[1]);
|
||||
const clipped = clip(text, start, end, [located], MAX_EXCERPT);
|
||||
if (clipped) return { text: clipped.text, spans: clipped.spans, quote: true };
|
||||
}
|
||||
}
|
||||
return bestSentence(text, activityQuery(activity));
|
||||
}
|
||||
|
||||
/** Splits a passage into pieces for rendering, marking the highlighted ones. */
|
||||
export function highlightSegments(
|
||||
text: string,
|
||||
spans: Array<[number, number]>,
|
||||
): Array<{ text: string; hit: boolean }> {
|
||||
const merged = [...spans]
|
||||
.filter(([from, to]) => to > from && from >= 0 && to <= text.length)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.reduce<Array<[number, number]>>((list, span) => {
|
||||
const last = list[list.length - 1];
|
||||
if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]);
|
||||
else list.push([span[0], span[1]]);
|
||||
return list;
|
||||
}, []);
|
||||
if (merged.length === 0) return [{ text, hit: false }];
|
||||
const out: Array<{ text: string; hit: boolean }> = [];
|
||||
let at = 0;
|
||||
for (const [from, to] of merged) {
|
||||
if (from > at) out.push({ text: text.slice(at, from), hit: false });
|
||||
out.push({ text: text.slice(from, to), hit: true });
|
||||
at = to;
|
||||
}
|
||||
if (at < text.length) out.push({ text: text.slice(at), hit: false });
|
||||
return out;
|
||||
}
|
||||
|
|
@ -64,28 +64,28 @@ export const VISION_USER_INSTRUCTION = `Transcribe these notes and extract the s
|
|||
/* Step 2 - activity generation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist:
|
||||
export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist. Each one names the material it suits best - pick types that fit the notes, never force a type that does not.
|
||||
|
||||
1. flashcard - tap to flip
|
||||
1. flashcard - tap to flip. Best for language vocabulary, terms to remember and quick recall.
|
||||
{ "type": "flashcard", "topic": "short label", "front": "question or term", "back": "answer or definition", "explanation": "why this matters" }
|
||||
|
||||
2. multiple_choice - exactly 4 options, exactly one correct
|
||||
2. multiple_choice - exactly 4 options, exactly one correct. Best for single facts that are easy to confuse, and for definitions that need a plausible-sounding wrong option.
|
||||
{ "type": "multiple_choice", "topic": "short label", "question": "...", "options": ["A", "B", "C", "D"], "correctIndex": 0, "explanation": "why the correct option is right and the others are wrong" }
|
||||
Put the correct answer at a varied position across the deck (not always 0!).
|
||||
|
||||
3. true_false - one statement
|
||||
3. true_false - one statement. Best for fine distinctions, exceptions and common mix-ups.
|
||||
{ "type": "true_false", "topic": "short label", "statement": "...", "answer": true, "explanation": "why it is true/false" }
|
||||
Roughly half of them should be false, and false statements must be subtly wrong (a swapped term, a wrong number), never absurd.
|
||||
|
||||
4. fill_blank - the sentence contains the placeholder "____"
|
||||
4. fill_blank - the sentence contains the placeholder "____". Best for key terms, formulas and language vocabulary that has to be produced inside a sentence (genders, prepositions, endings).
|
||||
{ "type": "fill_blank", "topic": "short label", "sentence": "Mitochondria produce ____ for the cell.", "answer": "ATP", "options": ["ATP", "DNA", "glucose", "water"], "explanation": "..." }
|
||||
"options" is optional but preferred: 3-6 plausible choices including the answer.
|
||||
|
||||
5. matching - 3 to 6 term/definition pairs
|
||||
5. matching - 3 to 6 term/definition pairs. Best for vocabulary, terms to definitions, symbols to meanings, words to translations, dates to events - the strongest type for language decks.
|
||||
Every term and definition must be distinct: a definition describes exactly one term, so never reuse a definition for two pairs.
|
||||
{ "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). Best for material with a real sequence.
|
||||
Use it ONLY when the notes contain a real sequence the learner can reason about: historical or
|
||||
chronological events, the stages of a process, steps of a procedure, a lifecycle, a ranked scale.
|
||||
If the notes only list unrelated things (works, authors, terms, places, values), do NOT invent an
|
||||
|
|
@ -95,11 +95,11 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type
|
|||
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. Best for asking why or how something works, or for applying a rule.
|
||||
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea the question asks for", "second idea"], "explanation": "..." }
|
||||
keyPoints must contain only the ideas needed to answer the question — never extras the question does not ask about (dates, places, names, numbers). For a "why" question require the reason, not the year.
|
||||
|
||||
Every activity must also have "topic" (2-4 words) and "explanation" (one or two sentences, plain language).`;
|
||||
Every activity must also have "topic" (2-4 words), "explanation" (one or two sentences, plain language) and "source": the exact words from the notes that prove the answer — one phrase or one sentence, at most 25 words, copied verbatim (no ellipses, no edits, same language as the notes). If several places mention it, pick the clearest one. The app shows this quote back to the learner under "From your notes".`;
|
||||
|
||||
export const GENERATION_SYSTEM_PROMPT = `You are Freezmo's activity designer. You turn a student's own notes into a deck of short, playful, interactive study activities.
|
||||
|
||||
|
|
@ -109,6 +109,7 @@ Hard rules:
|
|||
- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes do not contain enough material for the requested number of activities, create fewer - quality over quantity.
|
||||
- Every explanation must be answerable from the notes; do not introduce outside knowledge.
|
||||
- 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.
|
||||
- Match the mix to the material: language decks lean on flashcards, matching and fill_blank; historical or process material on ordering and true_false; reasoning and explanations on short_answer and multiple_choice.
|
||||
- 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.
|
||||
- Every option, item, term and definition on a card must be unique: never repeat the same wording
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ const baseActivity = {
|
|||
/** Short label shown in the feed chrome, e.g. "Cell biology". */
|
||||
topic: z.string().min(1).max(120).optional(),
|
||||
explanation: z.string().min(1).max(600),
|
||||
/**
|
||||
* The words from the student's own notes that carry this answer. The feed
|
||||
* shows them back after answering ("From your notes", with the matching
|
||||
* passage highlighted), so the quote has to be verbatim.
|
||||
*/
|
||||
source: z.string().min(1).max(1200).optional(),
|
||||
};
|
||||
|
||||
export const flashcardSchema = z.object({
|
||||
|
|
|
|||
150
lib/speech.ts
Normal file
150
lib/speech.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Text-to-speech for study cards, on the browser's own Web Speech API.
|
||||
*
|
||||
* No dependency and no audio leaves the app ourselves: the voice, the engine
|
||||
* and any cloud fallback belong to the operating system/browser. The module
|
||||
* keeps one utterance at a time app-wide (two cards reading over each other is
|
||||
* never what anyone wants) and notifies subscribers so the button that started
|
||||
* it can flip to "stop".
|
||||
*/
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let speakingId: string | null = null;
|
||||
|
||||
function notify(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function speechAvailable(): boolean {
|
||||
return typeof window !== "undefined" && "speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined";
|
||||
}
|
||||
|
||||
/** Language names ("Czech") and codes ("cs") → a BCP-47 tag for the voice. */
|
||||
const LOCALES: Record<string, string> = {
|
||||
english: "en-US",
|
||||
czech: "cs-CZ",
|
||||
slovak: "sk-SK",
|
||||
polish: "pl-PL",
|
||||
german: "de-DE",
|
||||
french: "fr-FR",
|
||||
spanish: "es-ES",
|
||||
italian: "it-IT",
|
||||
portuguese: "pt-PT",
|
||||
dutch: "nl-NL",
|
||||
swedish: "sv-SE",
|
||||
norwegian: "nb-NO",
|
||||
danish: "da-DK",
|
||||
finnish: "fi-FI",
|
||||
hungarian: "hu-HU",
|
||||
romanian: "ro-RO",
|
||||
turkish: "tr-TR",
|
||||
croatian: "hr-HR",
|
||||
slovenian: "sl-SI",
|
||||
serbian: "sr-RS",
|
||||
russian: "ru-RU",
|
||||
ukrainian: "uk-UA",
|
||||
bulgarian: "bg-BG",
|
||||
belarusian: "be-BY",
|
||||
macedonian: "mk-MK",
|
||||
greek: "el-GR",
|
||||
chinese: "zh-CN",
|
||||
mandarin: "zh-CN",
|
||||
japanese: "ja-JP",
|
||||
korean: "ko-KR",
|
||||
arabic: "ar-SA",
|
||||
persian: "fa-IR",
|
||||
farsi: "fa-IR",
|
||||
urdu: "ur-PK",
|
||||
hebrew: "he-IL",
|
||||
hindi: "hi-IN",
|
||||
marathi: "mr-IN",
|
||||
nepali: "ne-NP",
|
||||
thai: "th-TH",
|
||||
vietnamese: "vi-VN",
|
||||
};
|
||||
|
||||
/**
|
||||
* A BCP-47 tag for the deck's language, falling back to the interface locale
|
||||
* (so an untagged deck reads with the voice the user already hears the app in).
|
||||
*/
|
||||
export function speechLocale(language: string | undefined, fallback: string): string {
|
||||
const key = (language ?? "").trim().toLowerCase().replace(/[_.]/g, "-").split("-")[0].replace(/[^a-z]/g, "");
|
||||
if (key && LOCALES[key]) return LOCALES[key];
|
||||
if (key.length === 2) return language!.trim();
|
||||
return fallback === "cs" ? "cs-CZ" : "en-US";
|
||||
}
|
||||
|
||||
/** The installed voice closest to `locale` (exact match, then language). */
|
||||
function pickVoice(locale: string): SpeechSynthesisVoice | null {
|
||||
try {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length === 0) return null;
|
||||
const wanted = locale.toLowerCase();
|
||||
const prefix = wanted.split("-")[0];
|
||||
return (
|
||||
voices.find((voice) => voice.lang.toLowerCase() === wanted) ??
|
||||
voices.find((voice) => voice.lang.toLowerCase().replace("_", "-") === wanted) ??
|
||||
voices.find((voice) => voice.lang.toLowerCase().startsWith(`${prefix}-`) || voice.lang.toLowerCase() === prefix) ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `text` aloud under `id` (usually the button's own React id, so the
|
||||
* button that started it is the one that shows "stop").
|
||||
*/
|
||||
export function speak(id: string, text: string, locale: string): void {
|
||||
if (!speechAvailable()) return;
|
||||
const clean = text.replace(/…/g, ", ").replace(/_{2,}/g, "… ").trim();
|
||||
if (!clean) return;
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(clean);
|
||||
utterance.lang = locale;
|
||||
utterance.rate = 0.95;
|
||||
const voice = pickVoice(locale);
|
||||
if (voice) utterance.voice = voice;
|
||||
const done = () => {
|
||||
if (speakingId === id) {
|
||||
speakingId = null;
|
||||
notify();
|
||||
}
|
||||
};
|
||||
utterance.onend = done;
|
||||
utterance.onerror = done;
|
||||
speakingId = id;
|
||||
notify();
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch {
|
||||
speakingId = null;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function stopSpeaking(): void {
|
||||
if (!speechAvailable()) return;
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
if (speakingId !== null) {
|
||||
speakingId = null;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/** True while `id`'s utterance is the one playing. */
|
||||
export function isSpeaking(id: string): boolean {
|
||||
return speakingId === id;
|
||||
}
|
||||
|
||||
export function subscribeSpeech(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v82";
|
||||
const CACHE_VERSION = "freezmo-v83";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
209
tests/activities-test.mjs
Normal file
209
tests/activities-test.mjs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* The study-card extras: "From your notes" (the AI's own quote, and the local
|
||||
* sentence match for decks made before quotes existed), read-aloud cards
|
||||
* (Web Speech API, with the engine stubbed so the suite stays deterministic)
|
||||
* and timed runs (the restart-able countdown in the feed chrome).
|
||||
*/
|
||||
import { BASE, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
|
||||
|
||||
const { check, finish } = makeChecker();
|
||||
const browser = await launchBrowser();
|
||||
|
||||
const NOTES =
|
||||
"Rayleigh scattering makes the sky blue: molecules scatter short wavelengths far more than long ones. Chlorophyll absorbs red and blue light, which is why plants look green.";
|
||||
|
||||
/** Two cards: one with the generator's verbatim quote, one from before that. */
|
||||
const DECK = {
|
||||
id: "seed_deck",
|
||||
title: "Sky deck",
|
||||
subject: "Physics",
|
||||
summary: "Light in the atmosphere.",
|
||||
language: "English",
|
||||
sourceText: NOTES,
|
||||
difficulty: "easy",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
attempts: 0,
|
||||
activities: [
|
||||
{
|
||||
type: "multiple_choice",
|
||||
id: "a1",
|
||||
xp: 10,
|
||||
topic: "Sky",
|
||||
question: "What colour is a clear daytime sky?",
|
||||
options: ["Blue", "Green", "Orange", "Pink"],
|
||||
correctIndex: 0,
|
||||
explanation: "Short wavelengths scatter more.",
|
||||
source: "molecules scatter short wavelengths far more than long ones",
|
||||
},
|
||||
{
|
||||
type: "flashcard",
|
||||
id: "a2",
|
||||
xp: 5,
|
||||
topic: "Sky",
|
||||
front: "What makes the sky blue?",
|
||||
back: "Rayleigh scattering",
|
||||
explanation: "Small molecules scatter short wavelengths.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* 1. From your notes + read aloud --------------------------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
||||
await seed(page, { decks: [DECK], theme: "dark" });
|
||||
// Deterministic speech: the engine is a recorder, the utterance API is real,
|
||||
// and nothing ever fires "end" — so the button settles on its playing state.
|
||||
await page.addInitScript(() => {
|
||||
window.__spoken = [];
|
||||
window.__cancelled = 0;
|
||||
Object.defineProperty(window, "speechSynthesis", {
|
||||
configurable: true,
|
||||
value: {
|
||||
speak: (utterance) => window.__spoken.push(String(utterance.text)),
|
||||
cancel: () => {
|
||||
window.__cancelled += 1;
|
||||
},
|
||||
getVoices: () => [],
|
||||
speaking: false,
|
||||
paused: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const first = page.locator("section[aria-label]").nth(0);
|
||||
const second = page.locator("section[aria-label]").nth(1);
|
||||
|
||||
/* The AI's own quote: located in the stored notes, quote highlighted. */
|
||||
await first.getByRole("button", { name: "Blue" }).click();
|
||||
await page.waitForTimeout(600);
|
||||
const quoted = first.locator("[data-notes-source]");
|
||||
check("source: the feedback shows where the answer came from", await quoted.isVisible());
|
||||
check(
|
||||
"source: the quote's sentence from the notes is shown",
|
||||
((await quoted.textContent()) ?? "").includes("Rayleigh scattering makes the sky blue"),
|
||||
((await quoted.textContent()) ?? "").slice(0, 80),
|
||||
);
|
||||
const quotedMark = quoted.locator("mark").first();
|
||||
check(
|
||||
"source: the quote itself is the highlighted part",
|
||||
((await quotedMark.textContent()) ?? "").includes("molecules scatter short wavelengths far more than long ones"),
|
||||
String(await quotedMark.textContent()),
|
||||
);
|
||||
const markRgb = await quotedMark.evaluate((element) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = getComputedStyle(element).backgroundColor;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return { r, g, b };
|
||||
});
|
||||
check("source: the highlight is green", markRgb.g > markRgb.r + 10 && markRgb.g > markRgb.b, JSON.stringify(markRgb));
|
||||
check("source: the quote path is flagged", (await quoted.getAttribute("data-notes-quote")) === "1");
|
||||
|
||||
/* Read aloud, on the flashcard that follows. */
|
||||
await first.getByRole("button", { name: "Next" }).click();
|
||||
await page.waitForTimeout(900);
|
||||
const speak = second.getByRole("button", { name: "Read aloud" }).first();
|
||||
check("speak: the card carries a read-aloud control", (await speak.count()) === 1);
|
||||
await speak.click();
|
||||
await page.waitForTimeout(300);
|
||||
check(
|
||||
"speak: the visible side is handed to the speech engine",
|
||||
((await page.evaluate(() => window.__spoken)) ?? []).includes("What makes the sky blue?"),
|
||||
JSON.stringify(await page.evaluate(() => window.__spoken)),
|
||||
);
|
||||
check("speak: the control flips to stop", (await second.getByRole("button", { name: "Stop reading" }).count()) === 1);
|
||||
check(
|
||||
"speak: tapping the speaker does not flip the card",
|
||||
(await second.getByRole("button", { name: "Reveal the answer" }).count()) === 1,
|
||||
);
|
||||
await second.getByRole("button", { name: "Stop reading" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check("speak: stopping cancels the utterance", (await page.evaluate(() => window.__cancelled)) >= 1);
|
||||
check("speak: the control flips back", (await second.getByRole("button", { name: "Read aloud" }).count()) === 1);
|
||||
|
||||
/* The local fallback: this card has no quote, so the sentence is matched. */
|
||||
await second.getByRole("button", { name: "Reveal the answer" }).click();
|
||||
await page.waitForTimeout(600);
|
||||
await second.getByRole("button", { name: /I knew it/ }).click();
|
||||
await page.waitForTimeout(800);
|
||||
const local = second.locator("[data-notes-source]");
|
||||
check("source: a deck without quotes still points at the notes", await local.isVisible());
|
||||
check(
|
||||
"source: the local match is the right sentence",
|
||||
((await local.textContent()) ?? "").includes("Rayleigh scattering makes the sky blue"),
|
||||
((await local.textContent()) ?? "").slice(0, 80),
|
||||
);
|
||||
check("source: the matched words are marked", (await local.locator("mark").count()) >= 1);
|
||||
check("source: the local path is flagged", (await local.getAttribute("data-notes-quote")) === "0");
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 2. timed runs --------------------------------------------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
||||
await seed(page, { decks: [DECK] });
|
||||
await page.clock.install();
|
||||
// The fake clock has no animation frames, so the browser's smooth scrolling
|
||||
// would never arrive (the feed tracks its slide from the scroll position).
|
||||
// The harness scrolls instantly; everything else is the real app.
|
||||
await page.addInitScript(() => {
|
||||
const original = Element.prototype.scrollTo;
|
||||
Element.prototype.scrollTo = function (options, y) {
|
||||
if (options && typeof options === "object") return original.call(this, { ...options, behavior: "auto" });
|
||||
return original.call(this, options, y);
|
||||
};
|
||||
});
|
||||
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const toggle = page.locator("[data-timed-toggle]");
|
||||
check(
|
||||
"timed: the chrome carries the toggle, off by default",
|
||||
(await toggle.count()) === 1 && (await toggle.getAttribute("aria-pressed")) === "false",
|
||||
);
|
||||
check("timed: the label names the per-card budget", /30s/.test((await toggle.textContent()) ?? ""), String(await toggle.textContent()));
|
||||
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
check("timed: toggled on", (await toggle.getAttribute("aria-pressed")) === "true");
|
||||
await page.clock.runFor(5000);
|
||||
await page.waitForTimeout(200);
|
||||
const afterFive = Number.parseInt((await toggle.textContent()) ?? "", 10);
|
||||
check("timed: the countdown ticks down", afterFive <= 26 && afterFive > 0, String(afterFive));
|
||||
|
||||
await page.clock.runFor(26_000);
|
||||
await page.waitForTimeout(300);
|
||||
/* the feed advances a beat after the countdown hits zero */
|
||||
await page.clock.runFor(1000);
|
||||
await page.waitForTimeout(300);
|
||||
check("timed: running out tells the learner", (await page.getByText(/Time's up/).count()) >= 1);
|
||||
/* The feed's position, not the smooth-scroll animation: under the fake
|
||||
clock the browser's smooth scrolling has no frames of its own. */
|
||||
const position = await page.evaluate(() => {
|
||||
const chip = document.querySelector("header .study-chrome span.tabular-nums");
|
||||
return chip ? chip.textContent?.trim() ?? "" : "";
|
||||
});
|
||||
check("timed: the feed moves on by itself", position.startsWith("2/"), position);
|
||||
check(
|
||||
"timed: the timeout is recorded as a miss",
|
||||
(await page.locator("[data-testid=study-feed]").evaluate((element) => element.scrollTop)) > 100,
|
||||
);
|
||||
const fresh = Number.parseInt((await toggle.textContent()) ?? "", 10);
|
||||
check("timed: the next card gets a fresh budget", fresh >= 25 && fresh <= 30, String(fresh));
|
||||
|
||||
/* The second card times out too: the results screen counts the miss. */
|
||||
await page.clock.runFor(31_000);
|
||||
await page.waitForTimeout(600);
|
||||
check(
|
||||
"timed: both timeouts reach the results slide as misses",
|
||||
(await page.getByText(/You got 0 of 2 right/).count()) === 1 && (await page.getByText(/Missed items \(2\)/).count()) === 1,
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
finish();
|
||||
|
|
@ -391,6 +391,12 @@ const browser = await launchBrowser();
|
|||
// feed for ArrowDown. Both activities have the correct answer at index 0.
|
||||
await page.keyboard.press("1");
|
||||
await page.waitForTimeout(500);
|
||||
/* The feedback shows, but the "from your notes" passage stays hidden in
|
||||
exam mode with the other reveals (the deck's notes do contain the answer). */
|
||||
check(
|
||||
"exam: no notes source under the feedback",
|
||||
(await page.getByText("Why:").count()) >= 1 && (await page.locator("[data-notes-source]").count()) === 0,
|
||||
);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press("2");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import path from "node:path";
|
|||
|
||||
const SUITES = [
|
||||
"regression",
|
||||
"activities-test",
|
||||
"bubbles-test",
|
||||
"mobile-test",
|
||||
"wizard-test",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue