feat(phase3): learning helpers — summary, glossary, flashcards (SM-2), quizzes
Deterministic, on-device, no model: - src/lib/learn pure modules (tokenize, summary [TextRank-ish], glossary [definition-pattern + frequency], flashcards [cloze/Q-A], srs [SM-2], quiz [MCQ with distractors]) — 37 unit tests. - Flashcard persistence: Dexie v4 + native v4 `flashcards` table; create/list/ listDue/updateSrs/delete/counts; cascades (transcript delete, course->Unsorted). - UI: transcript "Study aids" (generate summary+glossary, click-to-seek; create flashcards), Study screen (SM-2 review + Anki CSV export), per-lecture Quiz, library Study link with due-count badge. 215 tests green, 0 tsc errors, web export builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+148
-1
@@ -1,4 +1,4 @@
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { Link, Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -18,9 +18,18 @@ import { getRepo, type Transcript } from '@/lib/db';
|
||||
import { downloadText } from '@/lib/download';
|
||||
import { EXPORT_META, formatTranscript, type ExportFormat } from '@/lib/export';
|
||||
import { formatClock } from '@/lib/format';
|
||||
import {
|
||||
cardsFromGlossary,
|
||||
glossary,
|
||||
summarize,
|
||||
type GlossaryEntry,
|
||||
type SourcedSentence,
|
||||
} from '@/lib/learn';
|
||||
import type { Segment } from '@/lib/types';
|
||||
import { useTranscribe } from '@/stores/transcribeStore';
|
||||
|
||||
const ACCENT = '#3c87f7';
|
||||
|
||||
export default function TranscriptScreen() {
|
||||
const theme = useTheme();
|
||||
const { id, t } = useLocalSearchParams<{ id: string; t?: string }>();
|
||||
@@ -37,6 +46,14 @@ export default function TranscriptScreen() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
// Study aids (deterministic, computed on demand — no storage, no model).
|
||||
const [aids, setAids] = useState<{
|
||||
summary: SourcedSentence[];
|
||||
glossary: GlossaryEntry[];
|
||||
} | null>(null);
|
||||
const [creatingCards, setCreatingCards] = useState(false);
|
||||
const [cardsAdded, setCardsAdded] = useState<number | null>(null);
|
||||
|
||||
const scrollRef = useRef<ScrollView | null>(null);
|
||||
// Y offset of each rendered segment row, captured via onLayout, for scroll-to.
|
||||
const segmentYs = useRef<Record<number, number>>({});
|
||||
@@ -165,6 +182,38 @@ export default function TranscriptScreen() {
|
||||
downloadText(`${safeName}.${meta.ext}`, meta.mime, content);
|
||||
};
|
||||
|
||||
// Compute summary + glossary on demand (pure helpers, nothing persisted).
|
||||
const generateAids = () => {
|
||||
setAids({ summary: summarize(segments), glossary: glossary(segments) });
|
||||
setCardsAdded(null);
|
||||
};
|
||||
|
||||
// Seeds derived from the current glossary — drives the "Create N flashcards" label.
|
||||
const cardSeeds = useMemo(
|
||||
() => (aids ? cardsFromGlossary(aids.glossary) : []),
|
||||
[aids],
|
||||
);
|
||||
|
||||
const createCards = async () => {
|
||||
if (cardSeeds.length === 0 || creatingCards) return;
|
||||
setCreatingCards(true);
|
||||
try {
|
||||
const created = await getRepo().createFlashcards(
|
||||
cardSeeds.map((s) => ({
|
||||
transcriptId: id,
|
||||
courseId: transcript?.courseId ?? null,
|
||||
segmentId: s.segmentId,
|
||||
start: s.start,
|
||||
front: s.front,
|
||||
back: s.back,
|
||||
})),
|
||||
);
|
||||
setCardsAdded(created.length);
|
||||
} finally {
|
||||
setCreatingCards(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (transcript === undefined) return <Centered><ActivityIndicator /></Centered>;
|
||||
if (transcript === null)
|
||||
return (
|
||||
@@ -205,6 +254,96 @@ export default function TranscriptScreen() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Study aids — deterministic helpers, computed on demand. */}
|
||||
<ThemedView type="backgroundElement" style={styles.aidsCard}>
|
||||
<ThemedText type="smallBold">Study aids</ThemedText>
|
||||
<View style={styles.aidsRow}>
|
||||
<Pressable
|
||||
onPress={generateAids}
|
||||
disabled={segments.length === 0}
|
||||
style={({ pressed }) => [
|
||||
styles.aidBtn,
|
||||
{ backgroundColor: ACCENT, opacity: segments.length === 0 ? 0.5 : pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.aidBtnText}>
|
||||
{aids ? 'Regenerate summary & glossary' : 'Generate summary & glossary'}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
<Link href={{ pathname: '/quiz', params: { id } }} asChild>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.aidBtn,
|
||||
{ backgroundColor: theme.backgroundSelected, opacity: pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.aidBtnTextAlt}>Quiz this lecture</ThemedText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
{aids && (
|
||||
<>
|
||||
{aids.summary.length > 0 ? (
|
||||
<View style={styles.aidSection}>
|
||||
<ThemedText type="smallBold" themeColor="textSecondary">Summary</ThemedText>
|
||||
{aids.summary.map((s, i) => (
|
||||
<Pressable key={`sum-${i}`} onPress={() => seek(s.start)} hitSlop={4}>
|
||||
<View style={styles.aidLine}>
|
||||
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
|
||||
{formatClock(s.start)}
|
||||
</ThemedText>
|
||||
<ThemedText type="small" style={styles.flex}>{s.text}</ThemedText>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ThemedText type="small" themeColor="textSecondary">No summary could be derived.</ThemedText>
|
||||
)}
|
||||
|
||||
{aids.glossary.length > 0 ? (
|
||||
<View style={styles.aidSection}>
|
||||
<ThemedText type="smallBold" themeColor="textSecondary">Glossary</ThemedText>
|
||||
{aids.glossary.map((g, i) => (
|
||||
<Pressable key={`glo-${i}`} onPress={() => seek(g.start)} hitSlop={4}>
|
||||
<View style={styles.aidLine}>
|
||||
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
|
||||
{formatClock(g.start)}
|
||||
</ThemedText>
|
||||
<ThemedText type="small" style={styles.flex}>
|
||||
<ThemedText type="smallBold">{g.term}</ThemedText>
|
||||
{` — ${g.definition}`}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ThemedText type="small" themeColor="textSecondary">No glossary terms found.</ThemedText>
|
||||
)}
|
||||
|
||||
{cardSeeds.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => void createCards()}
|
||||
disabled={creatingCards}
|
||||
style={({ pressed }) => [
|
||||
styles.aidBtn,
|
||||
{ backgroundColor: ACCENT, opacity: creatingCards ? 0.6 : pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.aidBtnText}>
|
||||
{creatingCards ? 'Adding…' : `Create ${cardSeeds.length} flashcards`}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{cardsAdded !== null && (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Added {cardsAdded} {cardsAdded === 1 ? 'card' : 'cards'}.
|
||||
</ThemedText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{segments.map((s, i) => (
|
||||
<View
|
||||
key={i}
|
||||
@@ -272,6 +411,14 @@ const styles = StyleSheet.create({
|
||||
title: { fontSize: 22, fontWeight: '700', paddingVertical: Spacing.two },
|
||||
exportRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two, marginBottom: Spacing.two },
|
||||
chip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
|
||||
aidsCard: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two, marginBottom: Spacing.two },
|
||||
aidsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two },
|
||||
aidBtn: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: Spacing.two, alignItems: 'center' },
|
||||
aidBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
||||
aidBtnTextAlt: { fontWeight: '700', fontSize: 14 },
|
||||
aidSection: { gap: Spacing.one, marginTop: Spacing.one },
|
||||
aidLine: { flexDirection: 'row', gap: Spacing.two, alignItems: 'flex-start', paddingVertical: 2 },
|
||||
flex: { flex: 1 },
|
||||
segRow: { flexDirection: 'row', gap: Spacing.two, paddingVertical: Spacing.one, paddingHorizontal: Spacing.two, borderRadius: Spacing.two, alignItems: 'flex-start' },
|
||||
ts: { paddingTop: 4, minWidth: 52 },
|
||||
segText: { flex: 1, fontSize: 16, lineHeight: 24, padding: 0 },
|
||||
|
||||
Reference in New Issue
Block a user