diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fdb8c6..e8d227e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Tutor + Librarian agents — mobile UI (AI-Agent-2/3) — mobile (2026-06-26) + +Mobile (Expo/React Native) parity for the two agent UIs shipped on web. **Tutor "Smart session"** — entry on the Vocabulary tab → `/tutor` screen: plan view (rationale + per-item exercise badge/difficulty/`why` + reading nudge), study via the existing mobile `FlashCard` rendered straight from the **enriched** plan item (no vocab re-fetch), HITL feedback re-plan → summary. **Librarian "Ask the librarian"** — entry on the Search tab → `/librarian` screen: NL query → reasoning + ranked cards, `library` recs tap through to `/book/[slug]`, `open_library` recs are dashed "Suggestion — not in your library yet" cards with **no navigation**, `usedExternal` note. Both faithfully port the web hardening: mounted-guard + `AbortController` (no setState after screen pop), feedback-failure retry re-submits the **same** session, `MAX_ROUNDS=8` client cap, clamped (`numberOfLines`) untrusted LLM text, unknown-`exerciseType` fallback. Implementation: `apps/mobile/src/lib/agents.ts` (DTO types + `authFetch` API + pure helpers), `useTutorSession`/`useLibrarian` hooks, `TutorPlanView` + `librarian/*` components, screens `app/tutor.tsx` + `app/librarian.tsx`; strings via shared `t()` (keys added to `packages/shared/src/i18n/en.json`), all colors via `useTheme()` (dark-mode correct), haptics/TTS wired. No new network processor (first-party `/me/tutor/*` + `/me/librarian` only — Play Data Safety unaffected). `npx tsc --noEmit` clean; **52 mobile vitest** green (14 new pure-helper tests). Adversarial QA: **0 blockers** — the two web→mobile regression risks (book route exists + correct; external never navigable) both confirmed. **On-device verification** owed (Expo Go can't run all native modules): nav to book + back-stack, Android keyboard-avoid on the librarian input, haptics/TTS on hardware, dark mode. **Deferred**: SSE streaming, external-book ingest, admin replay, tablet layout. This brings both shipped agents to full web+mobile parity. + ### Librarian Agent — web UI (Ask the librarian) (AI-Agent-3) — web (2026-06-24) The frontend for the Librarian agent (backend shipped earlier): a **"Ask the librarian"** natural-language book-discovery surface at `/:lang/discover` (entry from the Discover menu). Type a request — *"books like 1984 about surveillance, under 300 pages"* — → `POST /me/librarian` → the agent's **`reasoning`** + ranked recommendation cards, each with title/authors and the per-item, request-grounded **`why`**. **Library** results reuse the catalog book-card and link to `/:lang/books/{slug}`; **external** (`open_library`) results render as clearly-marked "**Suggestion — not in your library yet**" cards with **no in-app navigation** (the backend is recommend-only — an external suggestion must never masquerade as a real catalog book). A subtle `usedExternal` note shows when the librarian reached beyond the catalog. New `DiscoverPage`, `useLibrarian` state machine (`idle→asking→results|empty|error`), `LibrarianResults`/`LibrarianRecommendationCard`, `librarian.ts` client (`isValidLibrarianQuery` mirrors the backend ≥2/≤500 guard). Reuses the Tutor-UI hardening: mounted-guard + `AbortController` (no setState-after-unmount), `retry()` re-runs the same query, untrusted LLM strings (`reasoning`/`why`) line-clamped (`overflow-wrap:anywhere`) + React-escaped. Auth-gated (signed-out → existing auth modal CTA, stays on the page); SEO-noindexed (`/me/` surface). Adversarial QA: **0 blockers/should-fixes** — the two real risks (a library item with a null `slug` → no broken `/books/undefined` link; external item → never a link) are both guarded and test-covered. `tsc` clean; **595 web tests** green (11 new); `vite build` green; browser-checked (query → reasoning + library link + marked external suggestion + usedExternal + empty/error/min-query/signed-out, **0 console errors**). **Deferred**: mobile Librarian UI, SSE streaming, "add external book to my library"/ingest, pagination. Surfaces a shipped agent that previously had no UI. diff --git a/apps/mobile/app/(tabs)/search.tsx b/apps/mobile/app/(tabs)/search.tsx index f9ba7875..bffffc9a 100644 --- a/apps/mobile/app/(tabs)/search.tsx +++ b/apps/mobile/app/(tabs)/search.tsx @@ -288,6 +288,30 @@ export default function DiscoverScreen() { + {/* Ask the librarian — natural-language, reasoned recommendations (signed-in only; the screen gates). */} + {!searched && ( + router.push('/librarian')} + activeOpacity={0.85} + accessibilityRole="button" + accessibilityLabel={t('librarian.title')} + > + + + + + + {t('librarian.title')} + + + {t('librarian.entry.hint')} + + + + + )} + {loading ? ( {[0, 1, 2, 3].map(i => ( @@ -451,6 +475,19 @@ const styles = StyleSheet.create({ container: { flex: 1 }, center: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 }, searchBar: { padding: 12 }, + librarianEntry: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginHorizontal: 12, + marginBottom: 8, + padding: 12, + borderRadius: 12, + borderWidth: 1, + }, + librarianIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + librarianTitle: { fontSize: 15 }, + librarianSubtitle: { fontSize: 12, marginTop: 2 }, inputWrapper: { flexDirection: 'row', alignItems: 'center', diff --git a/apps/mobile/app/(tabs)/vocabulary.tsx b/apps/mobile/app/(tabs)/vocabulary.tsx index 57aa99d4..4bd6deae 100644 --- a/apps/mobile/app/(tabs)/vocabulary.tsx +++ b/apps/mobile/app/(tabs)/vocabulary.tsx @@ -297,8 +297,8 @@ export default function VocabularyScreen() { ))} - {dueCount > 0 && ( - + + {dueCount > 0 && ( router.push(`/vocabulary/review?reviewMode=${reviewMode}`)} @@ -306,8 +306,20 @@ export default function VocabularyScreen() { Practice ({dueCount}) - - )} + )} + {/* Smart session — AI tutor plans what to study and explains why (signed-in only; vocab tab is) */} + router.push('/tutor')} + accessibilityRole="button" + accessibilityLabel={t('tutor.entry.cta')} + > + + + {t('tutor.entry.cta')} + + + )} diff --git a/apps/mobile/app/librarian.tsx b/apps/mobile/app/librarian.tsx new file mode 100644 index 00000000..03793b12 --- /dev/null +++ b/apps/mobile/app/librarian.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { + View, Text, StyleSheet, SafeAreaView, ScrollView, TextInput, + ActivityIndicator, KeyboardAvoidingView, Platform, +} from 'react-native' +import { Ionicons } from '@expo/vector-icons' +import { useRouter, Stack } from 'expo-router' +import { useTheme } from '../src/context/ThemeContext' +import { useLanguage } from '../src/context/LanguageContext' +import { useAuth } from '../src/context/AuthContext' +import { useLibrarian } from '../src/hooks/useLibrarian' +import { isValidLibrarianQuery, MAX_QUERY_LENGTH } from '../src/lib/agents' +import { fonts } from '../src/theme/typography' +import { EmptyState } from '../src/components/ui/EmptyState' +import { PressableScale } from '../src/components/ui/PressableScale' +import { LibrarianResults } from '../src/components/librarian/LibrarianResults' + +// Ask the librarian (Librarian agent, AI-Agent-3). NL request → reasoning + ranked recommendation cards. +// Signed-in only (it's a `/me/` endpoint). `library` cards navigate to the in-app book; `open_library` cards +// are marked external suggestions with no navigation. +export default function LibrarianScreen() { + const { colors } = useTheme() + const { t } = useLanguage() + const { user } = useAuth() + const router = useRouter() + const librarian = useLibrarian() + const [query, setQuery] = useState('') + + const canAsk = isValidLibrarianQuery(query) && librarian.phase !== 'asking' + const handleAsk = () => { + if (canAsk) librarian.run(query.trim()) + } + const handleOpen = (slug: string) => router.push(`/book/${slug}`) + + const screen = + + // Signed-out + if (!user) { + return ( + <> + {screen} + + router.replace('/(auth)/login')} + /> + + + ) + } + + return ( + <> + {screen} + + + {/* Prompt */} + + + {t('librarian.subtitle')} + + + + {librarian.phase === 'asking' ? ( + + ) : ( + <> + + {t('librarian.ask')} + + )} + + + + + {librarian.phase === 'asking' && ( + + + + {t('librarian.thinking')} + + + )} + + {librarian.phase === 'empty' && ( + + )} + + {librarian.phase === 'error' && ( + librarian.retry()} + /> + )} + + {librarian.phase === 'results' && librarian.response && ( + + )} + + + + + ) +} + +const styles = StyleSheet.create({ + promptBox: { padding: 16, gap: 10 }, + subtitle: { fontSize: 13, lineHeight: 19 }, + input: { + minHeight: 90, + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + lineHeight: 21, + textAlignVertical: 'top', + }, + askBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 13, + borderRadius: 12, + }, + askBtnText: { color: '#fff', fontSize: 16 }, + results: { padding: 16, paddingTop: 0, paddingBottom: 32 }, + center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, gap: 16 }, + thinking: { fontSize: 14, textAlign: 'center' }, +}) diff --git a/apps/mobile/app/tutor.tsx b/apps/mobile/app/tutor.tsx new file mode 100644 index 00000000..24d2baf3 --- /dev/null +++ b/apps/mobile/app/tutor.tsx @@ -0,0 +1,263 @@ +import { useEffect } from 'react' +import { View, Text, StyleSheet, SafeAreaView, ActivityIndicator } from 'react-native' +import { Ionicons } from '@expo/vector-icons' +import { useRouter, Stack } from 'expo-router' +import { useTheme } from '../src/context/ThemeContext' +import { useLanguage } from '../src/context/LanguageContext' +import { useAuth } from '../src/context/AuthContext' +import { useTts } from '../src/hooks/useTts' +import { useHaptics } from '../src/hooks/useHaptics' +import { useTutorSession } from '../src/hooks/useTutorSession' +import { fonts } from '../src/theme/typography' +import { EmptyState } from '../src/components/ui/EmptyState' +import { PressableScale } from '../src/components/ui/PressableScale' +import { FlashCard } from '../src/components/vocabulary/FlashCard' +import { TutorPlanView } from '../src/components/vocabulary/TutorPlanView' +import { exerciseLabel } from '../src/lib/agents' + +// Smart session (Learning Tutor, AI-Agent-2). Layers the tutor's PLANNING + reasoning over the existing +// flashcard. Flow: plan view (the showcase) → study (reuse FlashCard) → feedback re-plan → summary. +export default function TutorScreen() { + const { colors } = useTheme() + const { t, language } = useLanguage() + const { user } = useAuth() + const router = useRouter() + const tutor = useTutorSession() + const { toggle: toggleTts } = useTts() + const haptics = useHaptics() + + const handleSpeak = (text: string) => toggleTts(text, { lang: language }) + const goBack = () => router.back() + + // Auto-plan once on mount when signed in. + useEffect(() => { + if (user) tutor.start() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user]) + + const screen = + + // Signed-out + if (!user) { + return ( + <> + {screen} + + router.replace('/(auth)/login')} + /> + + + ) + } + + // Planning / idle + if (tutor.phase === 'planning' || tutor.phase === 'idle') { + return ( + <> + {screen} + + + + + {t('tutor.planning')} + + + + + ) + } + + // Error (retry re-plans a fresh session, or re-submits pending feedback — see hook) + if (tutor.phase === 'error') { + return ( + <> + {screen} + + tutor.retry()} + /> + + + ) + } + + // Empty (nothing due — first plan came back empty) + if (tutor.phase === 'empty') { + return ( + <> + {screen} + + router.replace('/(tabs)/search')} + /> + + + ) + } + + // Summary + if (tutor.phase === 'summary') { + const { studied, correct } = tutor.stats + const rate = studied > 0 ? Math.round((correct / studied) * 100) : 0 + return ( + <> + {screen} + + + + + {t('tutor.summary.done')} + + + + {studied} + + {t('tutor.summary.studied')} + + + + {rate}% + + {t('tutor.summary.accuracy')} + + + + {!!tutor.readingNudge && ( + + 📖 + + {tutor.readingNudge} + + + )} + + {t('tutor.summary.back')} + + + + + ) + } + + // plan + study phases need an active turn + if (!tutor.turn) return screen + + // Plan showcase + if (tutor.phase === 'plan') { + return ( + <> + {screen} + + {tutor.adjusted && ( + + + + {t('tutor.plan.adjustedNote')} + + + )} + + + + ) + } + + // Study phase + const entry = tutor.currentEntry + if (!entry) return screen + const queue = tutor.turn.queue + const progress = queue.length > 0 ? (tutor.currentIndex / queue.length) * 100 : 0 + + const handleAnswer = (isCorrect: boolean, responseTimeMs: number) => { + haptics.play(isCorrect ? 'correct' : 'wrong') + tutor.answer(isCorrect, responseTimeMs) + } + + return ( + <> + + + {/* Progress */} + + + + + {/* Per-card reasoning — the tutor explains WHY this card */} + + + + {exerciseLabel(entry.item.exerciseType, t)} + + + + {entry.item.why} + + + + + handleAnswer(isCorrect, responseTimeMs)} + onSpeak={handleSpeak} + onFlip={() => haptics.play('flip')} + /> + + + + ) +} + +const styles = StyleSheet.create({ + planning: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 16 }, + planningText: { fontSize: 14, textAlign: 'center' }, + progressTrack: { height: 3 }, + progressFill: { height: '100%' }, + whyRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1 }, + whyBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10 }, + whyBadgeText: { fontSize: 11 }, + whyText: { flex: 1, fontSize: 13, lineHeight: 18 }, + cardArea: { flex: 1, padding: 20, justifyContent: 'center' }, + + adjustedNote: { flexDirection: 'row', alignItems: 'center', gap: 6, marginHorizontal: 16, marginTop: 12, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10 }, + adjustedText: { fontSize: 12 }, + + summary: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + summaryTitle: { fontSize: 22, marginTop: 16 }, + summaryStats: { flexDirection: 'row', gap: 40, marginTop: 24 }, + summaryStat: { alignItems: 'center' }, + summaryValue: { fontSize: 32 }, + summaryLabel: { fontSize: 12, marginTop: 2 }, + nudge: { flexDirection: 'row', gap: 8, borderRadius: 14, padding: 14, alignItems: 'flex-start', marginTop: 24, alignSelf: 'stretch' }, + nudgeIcon: { fontSize: 16 }, + nudgeText: { flex: 1, fontSize: 14, lineHeight: 20 }, + summaryBtn: { marginTop: 28, paddingVertical: 14, paddingHorizontal: 32, borderRadius: 12, alignSelf: 'stretch', alignItems: 'center' }, + summaryBtnText: { color: '#fff', fontSize: 16 }, +}) diff --git a/apps/mobile/src/components/librarian/LibrarianRecommendationCard.tsx b/apps/mobile/src/components/librarian/LibrarianRecommendationCard.tsx new file mode 100644 index 00000000..ce46cd8c --- /dev/null +++ b/apps/mobile/src/components/librarian/LibrarianRecommendationCard.tsx @@ -0,0 +1,114 @@ +import { View, Text, StyleSheet } from 'react-native' +import { Ionicons } from '@expo/vector-icons' +import { useTheme } from '../../context/ThemeContext' +import { fonts } from '../../theme/typography' +import { PressableScale } from '../ui/PressableScale' +import type { LibrarianRecommendation } from '../../lib/agents' + +interface Props { + index: number + rec: LibrarianRecommendation + t: (key: string) => string + onOpen: (slug: string) => void +} + +// A single ranked recommendation. Two visually-distinct treatments: +// - `library` (has a slug) → a tappable card that navigates into the catalog book page. +// - `open_library` → a clearly-marked "suggestion, not in your library yet" card with NO navigation. +// `title`/`why`/`authors` come straight from the model — `numberOfLines` bounds their height so an over-long +// string can't blow up the layout. +export function LibrarianRecommendationCard({ index, rec, t, onOpen }: Props) { + const { colors } = useTheme() + const isLibrary = rec.source === 'library' && !!rec.slug + const authors = rec.authors.length > 0 ? rec.authors.join(', ') : t('librarian.unknownAuthor') + + const meta: string[] = [] + if (rec.year != null) meta.push(String(rec.year)) + if (rec.pages != null) meta.push(`${rec.pages} ${t('librarian.pages')}`) + + const body = ( + <> + + {index + 1} + + + + + {rec.title} + + {!isLibrary && ( + + + {t('librarian.suggestionBadge')} + + + )} + + + {authors} + + {meta.length > 0 && ( + + {meta.join(' · ')} + + )} + + {rec.why} + + {!isLibrary ? ( + + {t('librarian.suggestionNote')} + + ) : ( + + + {t('librarian.openBook')} + + + + )} + + + ) + + if (isLibrary) { + return ( + onOpen(rec.slug!)} + style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]} + accessibilityRole="button" + accessibilityLabel={`${t('librarian.openBook')}: ${rec.title}`} + > + {body} + + ) + } + + // External suggestion: NOT navigable — it doesn't exist in the catalog yet. + return ( + + {body} + + ) +} + +const styles = StyleSheet.create({ + card: { flexDirection: 'row', gap: 12, borderRadius: 14, borderWidth: 1, padding: 14 }, + cardExternal: { borderStyle: 'dashed' }, + index: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center', marginTop: 2 }, + indexText: { fontSize: 13 }, + bodyText: { flex: 1 }, + head: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, + title: { fontSize: 18, flexShrink: 1 }, + badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, borderWidth: 1 }, + badgeText: { fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.4 }, + authors: { fontSize: 13, marginTop: 2 }, + meta: { fontSize: 12, marginTop: 2 }, + why: { fontSize: 14, lineHeight: 20, marginTop: 8 }, + suggestionNote: { fontSize: 12, marginTop: 8, fontStyle: 'italic' }, + openRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }, + openText: { fontSize: 13 }, +}) diff --git a/apps/mobile/src/components/librarian/LibrarianResults.tsx b/apps/mobile/src/components/librarian/LibrarianResults.tsx new file mode 100644 index 00000000..20eaa4ce --- /dev/null +++ b/apps/mobile/src/components/librarian/LibrarianResults.tsx @@ -0,0 +1,60 @@ +import { View, Text, StyleSheet } from 'react-native' +import { useTheme } from '../../context/ThemeContext' +import { fonts } from '../../theme/typography' +import { LibrarianRecommendationCard } from './LibrarianRecommendationCard' +import type { LibrarianResponse } from '../../lib/agents' + +interface Props { + response: LibrarianResponse + t: (key: string) => string + onOpen: (slug: string) => void +} + +// The showcase: surfaces the librarian's REASONING as a deliberate "here's what I found and why" block, then the +// ranked, grounded recommendation cards. `reasoning` is untrusted model text — `numberOfLines`-clamped. +export function LibrarianResults({ response, t, onOpen }: Props) { + const { colors } = useTheme() + const { reasoning, recommendations, usedExternal } = response + + return ( + + + + {t('librarian.reasoningLabel')} + + + {reasoning} + + + + {usedExternal && ( + + 🌐 + + {t('librarian.usedExternalNote')} + + + )} + + {recommendations.map((rec, i) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + container: { gap: 10 }, + reasoning: { borderRadius: 14, borderWidth: 1, padding: 14 }, + reasoningLabel: { fontSize: 12, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }, + reasoningText: { fontSize: 15, lineHeight: 21 }, + externalNote: { flexDirection: 'row', gap: 8, borderRadius: 14, padding: 14, alignItems: 'flex-start' }, + externalIcon: { fontSize: 16 }, + externalText: { flex: 1, fontSize: 13, lineHeight: 19 }, +}) diff --git a/apps/mobile/src/components/vocabulary/TutorPlanView.tsx b/apps/mobile/src/components/vocabulary/TutorPlanView.tsx new file mode 100644 index 00000000..6c0ad9d1 --- /dev/null +++ b/apps/mobile/src/components/vocabulary/TutorPlanView.tsx @@ -0,0 +1,120 @@ +import { View, Text, ScrollView, StyleSheet } from 'react-native' +import { useTheme } from '../../context/ThemeContext' +import { fonts } from '../../theme/typography' +import { PressableScale } from '../ui/PressableScale' +import { exerciseLabel, exerciseBadgeColor, type TutorPlanItem } from '../../lib/agents' + +interface Props { + rationale: string + plan: TutorPlanItem[] + readingNudge: string + adjusted: boolean + t: (key: string) => string + onStart: () => void +} + +// The showcase: surfaces the tutor's reasoning as a deliberate "here's your plan, and why" view — the visible +// reasoning is the point, not debug text. `rationale`/`why`/`difficulty`/`readingNudge` are untrusted LLM text, so +// every line is `numberOfLines`-clamped to bound height. +export function TutorPlanView({ rationale, plan, readingNudge, adjusted, t, onStart }: Props) { + const { colors } = useTheme() + const badgePalette = { + recognition: '#3B82F6', + recall: '#F59E0B', + context: '#8B5CF6', + fallback: colors.textSecondary, + } + + return ( + + {/* Rationale */} + + + {adjusted ? t('tutor.plan.adjustedLabel') : t('tutor.plan.rationaleLabel')} + + + {rationale} + + + + {/* Ordered plan */} + {plan.map((item, i) => { + const accent = exerciseBadgeColor(item.exerciseType, badgePalette) + return ( + + + {i + 1} + + + + + {item.word} + + + + {exerciseLabel(item.exerciseType, t)} + + + + {!!item.difficulty && ( + + {item.difficulty} + + )} + + {item.why} + + + + ) + })} + + {/* Reading nudge — ties back to the reading thesis */} + {!!readingNudge && ( + + 📖 + + {readingNudge} + + + )} + + + {t('tutor.plan.start')} + + + ) +} + +const styles = StyleSheet.create({ + content: { padding: 16, paddingBottom: 32, gap: 10 }, + rationale: { borderRadius: 14, borderWidth: 1, padding: 14 }, + rationaleLabel: { fontSize: 12, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }, + rationaleText: { fontSize: 15, lineHeight: 21 }, + item: { flexDirection: 'row', gap: 12, borderRadius: 14, borderWidth: 1, padding: 14 }, + index: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center', marginTop: 2 }, + indexText: { fontSize: 13 }, + itemBody: { flex: 1 }, + itemHead: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, + itemWord: { fontSize: 19, flexShrink: 1 }, + badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, borderWidth: 1 }, + badgeText: { fontSize: 11 }, + difficulty: { fontSize: 12, marginTop: 2 }, + why: { fontSize: 13, lineHeight: 18, marginTop: 6 }, + nudge: { flexDirection: 'row', gap: 8, borderRadius: 14, padding: 14, alignItems: 'flex-start' }, + nudgeIcon: { fontSize: 16 }, + nudgeText: { flex: 1, fontSize: 14, lineHeight: 20 }, + startBtn: { marginTop: 8, paddingVertical: 14, borderRadius: 12, alignItems: 'center' }, + startText: { color: '#fff', fontSize: 16 }, +}) diff --git a/apps/mobile/src/hooks/useLibrarian.ts b/apps/mobile/src/hooks/useLibrarian.ts new file mode 100644 index 00000000..98792556 --- /dev/null +++ b/apps/mobile/src/hooks/useLibrarian.ts @@ -0,0 +1,70 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { askLibrarian, isValidLibrarianQuery, type LibrarianResponse } from '../lib/agents' + +// Librarian (AI-Agent-3), mobile port of apps/web/src/hooks/useLibrarian.ts. +// - idle → prompt only, no request issued yet +// - asking → request in flight ("the librarian is thinking…") +// - results → got a non-empty recommendation list +// - empty → ran fine but found no good match +// - error → request failed (retryable) +export type LibrarianPhase = 'idle' | 'asking' | 'results' | 'empty' | 'error' + +export function useLibrarian() { + const [phase, setPhase] = useState('idle') + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + // The query the current/last request was issued for — kept so retry re-runs the SAME query. + const lastQueryRef = useRef('') + + // Mounted guard + in-flight abort so no setState fires after unmount (or after a superseded request). + const mountedRef = useRef(true) + const abortRef = useRef(null) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + abortRef.current?.abort() + } + }, []) + + const newSignal = useCallback(() => { + abortRef.current?.abort() + const ctrl = new AbortController() + abortRef.current = ctrl + return ctrl.signal + }, []) + + const run = useCallback(async (query: string) => { + // Client guard mirrors the backend min-length so we don't burn a rate-limited agent run on junk. + if (!isValidLibrarianQuery(query)) return + lastQueryRef.current = query + setPhase('asking') + setError(null) + const signal = newSignal() + try { + const res = await askLibrarian(query, signal) + if (!mountedRef.current || signal.aborted) return + setResponse(res) + setPhase(res.recommendations.length === 0 ? 'empty' : 'results') + } catch (err) { + if (!mountedRef.current || signal.aborted) return + setError(err instanceof Error ? err.message : 'Failed to reach the librarian') + setPhase('error') + } + }, [newSignal]) + + const retry = useCallback(() => { + if (lastQueryRef.current) void run(lastQueryRef.current) + }, [run]) + + const reset = useCallback(() => { + abortRef.current?.abort() + setPhase('idle') + setResponse(null) + setError(null) + lastQueryRef.current = '' + }, []) + + return { phase, response, error, run, retry, reset } +} diff --git a/apps/mobile/src/hooks/useTutorSession.ts b/apps/mobile/src/hooks/useTutorSession.ts new file mode 100644 index 00000000..e69c568b --- /dev/null +++ b/apps/mobile/src/hooks/useTutorSession.ts @@ -0,0 +1,206 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import type { ReviewCardDto } from '@textstack/shared' +import { + startTutorSession, + sendTutorFeedback, + buildQueue, + isSessionComplete, + type TutorPlanItem, + type TutorSessionResponse, + type TutorFeedbackResult, +} from '../lib/agents' + +// Learning Tutor (AI-Agent-2), mobile port of apps/web/src/hooks/useTutorSession.ts. The tutor PLANS what to +// study next over the learner's real SRS + reading state, then hands off to the existing flashcard. Flow: +// planning → plan (showcase) → study → (re-plan) → summary + empty/error. +// The plan is held server-side in a session so the HITL re-plan turn survives across requests. + +export type TutorPhase = 'idle' | 'planning' | 'plan' | 'study' | 'summary' | 'empty' | 'error' + +export interface TutorSessionStats { + studied: number + correct: number +} + +// Origin of an error so the error view can retry the RIGHT thing: +// - 'planning' → re-plan a fresh session via start() +// - 'feedback' → re-submit the SAME session's pending results (don't lose progress) +export type TutorErrorOrigin = 'planning' | 'feedback' + +const EMPTY_STATS: TutorSessionStats = { studied: 0, correct: 0 } + +// Belt-and-suspenders cap on the re-plan loop. The server also caps turns, but never trust it to stop. +const MAX_ROUNDS = 8 + +export interface TutorTurn { + rationale: string + readingNudge: string + plan: TutorPlanItem[] + queue: { item: TutorPlanItem; card: ReviewCardDto }[] +} + +export function useTutorSession() { + const [phase, setPhase] = useState('idle') + const [error, setError] = useState(null) + const [errorOrigin, setErrorOrigin] = useState(null) + const [sessionId, setSessionId] = useState(null) + const [turn, setTurn] = useState(null) + const [adjusted, setAdjusted] = useState(false) // true once the tutor has re-planned at least once + const [currentIndex, setCurrentIndex] = useState(0) + const [stats, setStats] = useState(EMPTY_STATS) + const [readingNudge, setReadingNudge] = useState('') + + // Results accumulated for the current turn's queue, fed back on completion. Kept on a ref (not state) so a + // feedback retry after an error re-sends the SAME pending results without losing the learner's progress. + const resultsRef = useRef([]) + // Re-plan round counter — client backstop against a server that keeps returning items. + const roundsRef = useRef(0) + // Mounted guard + in-flight abort so no setState fires after unmount when navigating away mid-plan. + const mountedRef = useRef(true) + const abortRef = useRef(null) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + abortRef.current?.abort() + } + }, []) + + // Replace any in-flight request's controller and hand back a fresh signal. + const newSignal = useCallback(() => { + abortRef.current?.abort() + const ctrl = new AbortController() + abortRef.current = ctrl + return ctrl.signal + }, []) + + const loadTurn = useCallback((res: TutorSessionResponse, isReplan: boolean) => { + if (!mountedRef.current) return + setSessionId(res.sessionId) + setReadingNudge(res.readingNudge) + if (isSessionComplete(res.plan)) { + setPhase(isReplan ? 'summary' : 'empty') + return + } + // Stop runaway loops: if the tutor keeps handing back work past the cap, end the session. + if (isReplan && roundsRef.current >= MAX_ROUNDS) { + setPhase('summary') + return + } + const queue = buildQueue(res.plan) + resultsRef.current = [] + setCurrentIndex(0) + setTurn({ rationale: res.rationale, readingNudge: res.readingNudge, plan: res.plan, queue }) + if (isReplan) setAdjusted(true) + setPhase('plan') + }, []) + + const start = useCallback(async (maxItems?: number) => { + setPhase('planning') + setError(null) + setErrorOrigin(null) + setAdjusted(false) + setStats(EMPTY_STATS) + roundsRef.current = 0 + resultsRef.current = [] + const signal = newSignal() + try { + const res = await startTutorSession(maxItems, signal) + if (!mountedRef.current) return + loadTurn(res, false) + } catch (err) { + if (!mountedRef.current || signal.aborted) return + setError(err instanceof Error ? err.message : 'Failed to plan session') + setErrorOrigin('planning') + setPhase('error') + } + }, [loadTurn, newSignal]) + + const beginStudy = useCallback(() => { + setPhase('study') + setCurrentIndex(0) + }, []) + + // Submit the feedback turn → either continue with the re-planned items or finish. On failure, keep the SAME + // session + pending results so retry re-submits (doesn't start a brand-new session). + const submitFeedback = useCallback(async () => { + if (!sessionId) return + setPhase('planning') + roundsRef.current += 1 + const signal = newSignal() + try { + const res = await sendTutorFeedback(sessionId, resultsRef.current, signal) + if (!mountedRef.current) return + loadTurn(res, true) + } catch (err) { + if (!mountedRef.current || signal.aborted) return + setError(err instanceof Error ? err.message : 'Failed to update plan') + setErrorOrigin('feedback') + setPhase('error') + } + }, [sessionId, loadTurn, newSignal]) + + // Retry after an error: re-plan a fresh session, or re-submit the same session's pending feedback. + const retry = useCallback(() => { + if (errorOrigin === 'feedback') { + setError(null) + setErrorOrigin(null) + void submitFeedback() + } else { + void start() + } + }, [errorOrigin, submitFeedback, start]) + + // Record a result for the current card and advance; on the last card, trigger the feedback re-plan. + const answer = useCallback((correct: boolean, responseTimeMs: number) => { + const queue = turn?.queue + if (!queue) return + const entry = queue[currentIndex] + if (!entry) return + resultsRef.current = [ + ...resultsRef.current, + { wordId: entry.item.wordId, correct, responseTimeMs }, + ] + setStats(prev => ({ studied: prev.studied + 1, correct: prev.correct + (correct ? 1 : 0) })) + const nextIdx = currentIndex + 1 + if (nextIdx >= queue.length) { + void submitFeedback() + } else { + setCurrentIndex(nextIdx) + } + }, [turn, currentIndex, submitFeedback]) + + const reset = useCallback(() => { + abortRef.current?.abort() + setPhase('idle') + setError(null) + setErrorOrigin(null) + setSessionId(null) + setTurn(null) + setAdjusted(false) + setCurrentIndex(0) + setStats(EMPTY_STATS) + resultsRef.current = [] + roundsRef.current = 0 + }, []) + + const currentEntry = turn?.queue[currentIndex] ?? null + + return { + phase, + error, + errorOrigin, + turn, + adjusted, + currentIndex, + currentEntry, + stats, + readingNudge, + start, + beginStudy, + answer, + retry, + reset, + } +} diff --git a/apps/mobile/src/lib/agents.test.ts b/apps/mobile/src/lib/agents.test.ts new file mode 100644 index 00000000..798e6aa1 --- /dev/null +++ b/apps/mobile/src/lib/agents.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest' +import { + isValidLibrarianQuery, + buildPlanCard, + buildQueue, + isSessionComplete, + exerciseLabel, + exerciseBadgeColor, + MIN_QUERY_LENGTH, + MAX_QUERY_LENGTH, + type TutorPlanItem, +} from './agents' + +function planItem(overrides: Partial = {}): TutorPlanItem { + return { + wordId: 'w1', + word: 'ephemeral', + stage: 2, + exerciseType: 'recall', + difficulty: 'Medium', + why: 'You missed this last time.', + translation: 'тимчасовий', + definition: 'lasting a very short time', + sentence: 'An ephemeral moment of joy.', + bookTitle: 'Some Book', + hint: 'starts with e', + distractors: ['a', 'b'], + ...overrides, + } +} + +describe('isValidLibrarianQuery', () => { + it('rejects too-short queries (after trim)', () => { + expect(isValidLibrarianQuery('')).toBe(false) + expect(isValidLibrarianQuery('a')).toBe(false) + expect(isValidLibrarianQuery(' a ')).toBe(false) + }) + + it('accepts queries at and above the minimum trimmed length', () => { + expect(isValidLibrarianQuery('ab')).toBe(true) + expect(isValidLibrarianQuery(' ab ')).toBe(true) + expect(isValidLibrarianQuery('books like 1984')).toBe(true) + }) + + it('rejects queries longer than the max (raw length, not trimmed)', () => { + expect(isValidLibrarianQuery('x'.repeat(MAX_QUERY_LENGTH))).toBe(true) + expect(isValidLibrarianQuery('x'.repeat(MAX_QUERY_LENGTH + 1))).toBe(false) + }) + + it('exposes the documented bounds', () => { + expect(MIN_QUERY_LENGTH).toBe(2) + expect(MAX_QUERY_LENGTH).toBe(500) + }) +}) + +describe('buildPlanCard', () => { + it('projects an enriched plan item into a context-mode ReviewCardDto', () => { + const card = buildPlanCard(planItem()) + expect(card).toMatchObject({ + wordId: 'w1', + word: 'ephemeral', + translation: 'тимчасовий', + definition: 'lasting a very short time', + reviewMode: 'context', + originalSentence: 'An ephemeral moment of joy.', + bookTitle: 'Some Book', + hint: 'starts with e', + isNew: false, + blankSentence: null, + explanation: null, + options: null, + correctOptionIndex: null, + }) + }) + + it('coerces missing optional fields to null (never undefined)', () => { + const card = buildPlanCard(planItem({ + translation: undefined, + definition: undefined, + sentence: undefined, + bookTitle: undefined, + hint: undefined, + })) + expect(card.translation).toBeNull() + expect(card.definition).toBeNull() + expect(card.originalSentence).toBeNull() + expect(card.bookTitle).toBeNull() + expect(card.hint).toBeNull() + }) +}) + +describe('buildQueue', () => { + it('produces one entry per plan item, preserving order, nothing dropped', () => { + const plan = [planItem({ wordId: 'a' }), planItem({ wordId: 'b' }), planItem({ wordId: 'c' })] + const queue = buildQueue(plan) + expect(queue).toHaveLength(3) + expect(queue.map(q => q.item.wordId)).toEqual(['a', 'b', 'c']) + expect(queue.map(q => q.card.wordId)).toEqual(['a', 'b', 'c']) + }) + + it('returns an empty queue for an empty plan', () => { + expect(buildQueue([])).toEqual([]) + }) +}) + +describe('isSessionComplete', () => { + it('is true only when the re-plan is empty', () => { + expect(isSessionComplete([])).toBe(true) + expect(isSessionComplete([planItem()])).toBe(false) + }) +}) + +describe('exerciseLabel', () => { + const t = (key: string) => `i18n:${key}` + + it('uses the i18n key for known exercise types', () => { + expect(exerciseLabel('recognition', t)).toBe('i18n:tutor.exercise.recognition') + expect(exerciseLabel('recall', t)).toBe('i18n:tutor.exercise.recall') + expect(exerciseLabel('context', t)).toBe('i18n:tutor.exercise.context') + }) + + it('falls back to the raw value for an unknown model type (no leaked i18n key)', () => { + expect(exerciseLabel('cloze_madness', t)).toBe('cloze_madness') + }) + + it('falls back to a generic label for blank/whitespace types', () => { + expect(exerciseLabel('', t)).toBe('i18n:tutor.exercise.generic') + expect(exerciseLabel(' ', t)).toBe('i18n:tutor.exercise.generic') + }) +}) + +describe('exerciseBadgeColor', () => { + const palette = { recognition: '#a', recall: '#b', context: '#c', fallback: '#z' } + + it('maps known types to their accent', () => { + expect(exerciseBadgeColor('recognition', palette)).toBe('#a') + expect(exerciseBadgeColor('recall', palette)).toBe('#b') + expect(exerciseBadgeColor('context', palette)).toBe('#c') + }) + + it('maps unknown types to the fallback', () => { + expect(exerciseBadgeColor('???', palette)).toBe('#z') + }) +}) diff --git a/apps/mobile/src/lib/agents.ts b/apps/mobile/src/lib/agents.ts new file mode 100644 index 00000000..275a402b --- /dev/null +++ b/apps/mobile/src/lib/agents.ts @@ -0,0 +1,193 @@ +import { authFetch } from '@textstack/shared' +import type { ReviewCardDto } from '@textstack/shared' + +// AI agents shared by the mobile Tutor ("Smart session") and Librarian ("Ask the librarian") screens. +// RN-free on purpose: the DTOs, the typed `authFetch` calls, and the pure helpers below are all unit-testable +// under Vitest (see agents.test.ts) without bundling React Native. Mirrors the web clients/hooks 1:1 +// (apps/web/src/api/{tutor,librarian}.ts) plus the same hardening (clamp untrusted text, unknown-type fallback, +// re-plan cap, client-side query guard). + +// --------------------------------------------------------------------------- +// Tutor (AI-Agent-2) — types mirror Contracts/Agents/TutorDtos.cs (camelCase via the API) +// --------------------------------------------------------------------------- + +/** + * One planned study item. The backend ENRICHES each item with the full card payload (translation, definition, + * sentence, bookTitle, hint, distractors), so the UI renders the study card straight from the plan — no separate + * vocab fetch + join. References a REAL vocab card by `wordId`, with per-item `why` reasoning. + */ +export interface TutorPlanItem { + wordId: string + word: string + stage: number + exerciseType: string // recognition | recall | context (untrusted — model may emit anything) + difficulty: string // label string + why: string // per-item reasoning + translation?: string | null + definition?: string | null + sentence?: string | null + bookTitle?: string | null + hint?: string | null + distractors: string[] // [] when none, never null +} + +/** The tutor's response: the persisted session, the ordered plan, and the surfaced reasoning. */ +export interface TutorSessionResponse { + sessionId: string + plan: TutorPlanItem[] + rationale: string // overall session reasoning + readingNudge: string // ties back to reading (the thesis) + runId: string +} + +/** One learner result fed back to the tutor for re-planning. */ +export interface TutorFeedbackResult { + wordId: string + correct: boolean + responseTimeMs: number +} + +/** Plan a new tutor session over the learner's current state. `maxItems` is optional (server-capped). */ +export function startTutorSession(maxItems?: number, signal?: AbortSignal): Promise { + return authFetch('/me/tutor/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(maxItems != null ? { maxItems } : {}), + signal, + }) +} + +/** + * Submit the learner's results for the current session and get the re-planned remainder. An empty `plan` in the + * response means the session is complete. + */ +export function sendTutorFeedback( + sessionId: string, + results: TutorFeedbackResult[], + signal?: AbortSignal, +): Promise { + return authFetch(`/me/tutor/session/${sessionId}/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ results }), + signal, + }) +} + +// --------------------------------------------------------------------------- +// Librarian (AI-Agent-3) — types mirror Contracts/Agents/LibrarianDtos.cs (camelCase via the API) +// --------------------------------------------------------------------------- + +export const MIN_QUERY_LENGTH = 2 +export const MAX_QUERY_LENGTH = 500 + +/** + * Where a recommendation came from. `library` → the book IS in the catalog (`slug`/`editionId` set) — link to its + * page. `open_library` → an external suggestion NOT in the library yet (no slug) — never navigate in-app. + */ +export type LibrarianSource = 'library' | 'open_library' + +/** One ranked recommendation. `why` is the per-item, request-grounded reason. */ +export interface LibrarianRecommendation { + source: LibrarianSource + editionId?: string | null + slug?: string | null + title: string + authors: string[] + why: string + language?: string | null + year?: number | null + pages?: number | null +} + +/** + * The librarian's response: ranked `recommendations`, the overall `reasoning`, `usedExternal` (did it expand to + * Open Library because the library was thin?) and the persisted `runId` for replay in the admin UI. + */ +export interface LibrarianResponse { + recommendations: LibrarianRecommendation[] + reasoning: string + usedExternal: boolean + runId: string +} + +/** Run the librarian on a natural-language request. Auth required (`/me/*`); rate-limited server-side. */ +export function askLibrarian(query: string, signal?: AbortSignal): Promise { + return authFetch('/me/librarian', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal, + }) +} + +// --------------------------------------------------------------------------- +// Pure helpers (RN-free, unit-tested) — shared between hooks/components +// --------------------------------------------------------------------------- + +/** Pure client guard mirroring the backend: ≥2 trimmed chars, ≤500 chars. Returns true when worth an agent run. */ +export function isValidLibrarianQuery(query: string): boolean { + const trimmed = query.trim() + return trimmed.length >= MIN_QUERY_LENGTH && query.length <= MAX_QUERY_LENGTH +} + +/** + * Builds a classic-flashcard `ReviewCardDto` directly from an ENRICHED plan item. The backend already validated + + * enriched every item, so there's no vocab fetch + join — the plan item is self-sufficient. Pure projection. + */ +export function buildPlanCard(item: TutorPlanItem): ReviewCardDto { + return { + wordId: item.wordId, + word: item.word, + translation: item.translation ?? null, + definition: item.definition ?? null, + reviewMode: 'context', + blankSentence: null, + originalSentence: item.sentence ?? null, + bookTitle: item.bookTitle ?? null, + hint: item.hint ?? null, + explanation: null, + isNew: false, + options: null, + correctOptionIndex: null, + } +} + +/** Projects an enriched plan into renderable study entries — one card per item, nothing dropped. */ +export function buildQueue(plan: TutorPlanItem[]): { item: TutorPlanItem; card: ReviewCardDto }[] { + return plan.map(item => ({ item, card: buildPlanCard(item) })) +} + +/** A re-plan with no items means the tutor decided the session is done. */ +export function isSessionComplete(plan: TutorPlanItem[]): boolean { + return plan.length === 0 +} + +const KNOWN_EXERCISE_TYPES = new Set(['recognition', 'recall', 'context']) + +/** + * Label for an exercise type. Known types resolve via i18n; anything unexpected from the model falls back to the + * raw value (or a generic label) rather than leaking `tutor.exercise.`. + */ +export function exerciseLabel(exerciseType: string, t: (key: string) => string): string { + if (KNOWN_EXERCISE_TYPES.has(exerciseType)) return t(`tutor.exercise.${exerciseType}`) + const raw = exerciseType?.trim() + return raw ? raw : t('tutor.exercise.generic') +} + +/** Known exercise types map to a themed accent color; unknown types get the neutral fallback. */ +export function exerciseBadgeColor( + exerciseType: string, + palette: { recognition: string; recall: string; context: string; fallback: string }, +): string { + switch (exerciseType) { + case 'recognition': + return palette.recognition + case 'recall': + return palette.recall + case 'context': + return palette.context + default: + return palette.fallback + } +} diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 8f3ca3b8..d347285d 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -130,4 +130,22 @@ export async function deleteAccount(accessToken: string): Promise { } } +// AI agent endpoints (Tutor "Smart session" + Librarian "Ask the librarian"). Implemented in ./agents on top of +// the shared `authFetch` (Bearer auth, base URL, error/status handling) — re-exported here so callers reach them +// through the consolidated api module, alongside the request types. +export { + startTutorSession, + sendTutorFeedback, + askLibrarian, + isValidLibrarianQuery, +} from './agents' +export type { + TutorPlanItem, + TutorSessionResponse, + TutorFeedbackResult, + LibrarianSource, + LibrarianRecommendation, + LibrarianResponse, +} from './agents' + export { API_URL } diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 04b725b0..42f7ed3e 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -35,6 +35,13 @@ export default defineConfig({ find: '@react-native-async-storage/async-storage', replacement: resolve(__dirname, 'src/lib/__mocks__/async-storage.ts'), }, + // Shared workspace package is a source path-alias (not built) — point + // Vitest at its source entry so lib tests that import RN-free helpers + // from modules which also re-export `authFetch` (e.g. agents.ts) resolve. + { + find: '@textstack/shared', + replacement: resolve(__dirname, '../../packages/shared/src/index.ts'), + }, ], }, define: { diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 9e407dbe..66af7a17 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -1,4 +1,77 @@ { + "tutor": { + "title": "Smart session", + "planning": "Your tutor is planning your session…", + "entry": { + "cta": "Smart session", + "hint": "Let your AI tutor plan what to study and explain why" + }, + "plan": { + "rationaleLabel": "Here's your plan, and why", + "adjustedLabel": "Your tutor adjusted your plan", + "adjustedNote": "Your tutor adjusted your plan based on how you did.", + "start": "Start studying" + }, + "exercise": { + "recognition": "Recognition", + "recall": "Recall", + "context": "Context", + "generic": "Exercise" + }, + "summary": { + "done": "Session complete", + "studied": "Studied", + "accuracy": "Accuracy", + "back": "Back to vocabulary" + }, + "empty": { + "title": "Nothing to study right now", + "subtitle": "You're all caught up. Keep reading to grow your vocabulary.", + "cta": "Browse books" + }, + "error": { + "title": "Couldn't plan your session", + "subtitle": "Something went wrong reaching your tutor.", + "retry": "Try again" + }, + "signIn": { + "title": "Sign in for a smart session", + "subtitle": "Your AI tutor plans what to study from the words you save while reading.", + "cta": "Sign in" + } + }, + "librarian": { + "title": "Ask the librarian", + "subtitle": "Describe what you want to read and the librarian reasons over the library to recommend the best fits.", + "placeholder": "Describe what you want to read — e.g. 'books like 1984 about surveillance, under 300 pages'", + "inputLabel": "What do you want to read?", + "ask": "Ask", + "thinking": "The librarian is thinking…", + "reasoningLabel": "Here's what I found and why", + "usedExternalNote": "The library was thin on this, so I reached beyond the catalog for a couple of suggestions.", + "suggestionBadge": "Suggestion", + "suggestionNote": "Not in your library yet — an outside suggestion.", + "openBook": "Open book", + "unknownAuthor": "Unknown author", + "pages": "pages", + "entry": { + "hint": "Tell the librarian what you want to read" + }, + "empty": { + "title": "Couldn't find a good match", + "subtitle": "Try rephrasing your request — add a theme, a comparable book, or a length." + }, + "error": { + "title": "Couldn't reach the librarian", + "subtitle": "Something went wrong. Please try again.", + "retry": "Try again" + }, + "signIn": { + "title": "Sign in to ask the librarian", + "subtitle": "The librarian recommends books from a natural-language request, with its reasoning.", + "cta": "Sign in" + } + }, "home": { "hero": { "title": "Learn languages by reading real books",