diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f3e089..1a54ad59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Learning Tutor Agent — web UI (Smart session) (AI-Agent-2) — web (2026-06-24) + +The frontend for the Tutor agent: a **"Smart session"** on the Vocabulary page that surfaces the tutor's *reasoning*, not just a card stack. `POST /me/tutor/session` → a **plan view** showing the overall `rationale`, each item's `word` + exercise-type badge + difficulty + per-item `why`, and a closing `readingNudge` (the thesis) — visible, intentional reasoning is the point. Then a **study phase** reusing the existing `FlashCard` (word → flip → Got it / Missed it, `responseTimeMs` measured), and a **HITL feedback loop**: `POST /me/tutor/session/{id}/feedback` re-plans the remainder ("your tutor adjusted your plan") until an empty plan ends it → summary (studied / accuracy / nudge). New `TutorSessionPage` at `/:lang/vocabulary/tutor`, `useTutorSession` state machine (`planning→plan→study→…→summary` + empty/error/signIn), `TutorPlanView`, `tutor.ts` client. **Backend DTO enrichment (anti-join):** to render cards the UI used to re-fetch the user's vocab and join by id — which silently dropped planned cards for users with >100 words (server caps `getWords` at 100 + a non-existent `'recent'` sort). Fixed at the source: `TutorEndpoints` now **loads each plan item's card from the DB scoped to the caller** (`Id IN ids AND UserId == userId` — a second anti-hallucination/isolation re-check) and enriches `TutorPlanItemDto` with `translation`/`definition`/`sentence`/`bookTitle`/`hint`/`distractors`, so the client renders straight from the plan with **no join, nothing dropped**. Also a **re-plan turn cap** (`MaxTurns=6` server-side + an 8-round client backstop) so a persistently-missed card can't loop forever. UI hardening from the adversarial pass: `AbortController`/mounted-guard (no setState-after-unmount), feedback-failure retry re-submits the **same** session (doesn't nuke progress), and untrusted LLM strings (`why`/`rationale`/`nudge`) are line-clamped with an unknown-`exerciseType` fallback (no raw i18n-key leak). `tsc` clean; **584 web + 35 backend Tutor + AiEvals** green; `vite build` green; browser-checked (entry→plan→study→re-plan→summary + empty/unknown-type/long-text/unmount, **0 console errors**). **Deferred**: mobile Tutor UI, SSE plan streaming, generated MC exercises beyond the existing card, admin replay link. Completes AI-Agent-2 (backend shipped earlier). + ### Learning Tutor Agent — plans what to study next over real SRS state (AI-Agent-2) — backend (2026-06-24) The third and largest agent: a **Tutor** that reasons over the learner's actual vocabulary state and **plans what to study next**, rather than running a fixed review queue. `TutorAgent` runs on the existing `AgentLoop` runtime and calls four thin `ITool`s — `get_due_vocabulary` (due/near-due SRS cards), `get_weak_vocabulary` (lowest-accuracy / earliest-stage words), `get_reading_context` (what they're actually reading — keeps practice tied to reading, the product thesis), and `get_example_sentence` (a real in-context sentence: the learner's saved sentence, else a **spoiler-gated, owner-isolated RAG** pull from their own book) — then emits an **ordered study plan** (`{wordId, word, stage, exerciseType, difficulty, why}` + an overall `rationale` + a `readingNudge`), exercise type/difficulty **recalibrated from the real SRS stage** (recognition→recall→context-cloze). **Server-held `tutor_session`** (new entity/table, jsonb `PlanJson`, status, turn count) persists the plan between turns; **HITL**: `POST /me/tutor/session` starts/resumes and `POST /me/tutor/session/{id}/feedback` re-plans on the learner's results — re-fetching state (so SRS updates are seen), deterministically **dropping cards just answered correctly**, ignoring feedback for ids not in the prior plan, and preserving the session length. **Two hard guarantees, QA-verified**: (1) **anti-hallucination** — every scheduled `wordId` must come from a `get_due`/`get_weak` tool result (harvested ok-only from the transcript), word+stage **re-projected** from the real row, invented ids dropped, empty transcript → empty plan (the model can't fabricate or rename a card); (2) **cross-user isolation** — the example-sentence tool resolves the card with `Id == wordId && UserId == userId` and the RAG path filters on `user_id AND user_book_id`, so no other user's `user_chapter_chunk` content is reachable. All inbound book text (example sentences from user uploads, reading titles) is run through `ExternalTextSanitizer` + length-capped before entering the prompt (prompt-injection boundary). Telemetry: each turn persists an `agent_run` (agent=`tutor`, `tool_calls_count`); route `tutor.agent → gpt-4.1-mini`. **Eval**: `TutorEvalRunner` (deterministic structural rubric over synthetic learner states — due-coverage, weak-targeting, difficulty-appropriateness, no-hallucination, thesis-alignment; a golden where weak ∉ due makes weak-targeting discriminating), admin-runnable `POST /admin/ai-quality/tutor/eval`. EF migration `AddTutorSession` (reversible). `dotnet build` green, `dotnet format` clean; 968 unit + 72 AiEvals tests green. **Deferred**: SSE streaming, the tutor UI surface (frontend/mobile slice), generated free-text exercises beyond MC reuse, longitudinal pedagogical-efficacy A/B (offline evals validate planner mechanics, not learning outcomes). Completes the 3-agent roadmap (`docs/04-dev/agents-roadmap.md`); Agent 1 (Enrichment) + Agent 3 (Librarian) already shipped. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 61ab9cdc..0cb664fa 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,6 +34,7 @@ const UserBookDetailPage = lazy(() => import('./pages/UserBookDetailPage').then( const StatsPage = lazy(() => import('./pages/StatsPage').then(m => ({ default: m.StatsPage }))) const VocabularyPage = lazy(() => import('./pages/VocabularyPage').then(m => ({ default: m.VocabularyPage }))) const VocabularyReviewPage = lazy(() => import('./pages/VocabularyReviewPage').then(m => ({ default: m.VocabularyReviewPage }))) +const TutorSessionPage = lazy(() => import('./pages/TutorSessionPage').then(m => ({ default: m.TutorSessionPage }))) const HighlightsPage = lazy(() => import('./pages/HighlightsPage').then(m => ({ default: m.HighlightsPage }))) const HighlightReviewPage = lazy(() => import('./pages/HighlightReviewPage').then(m => ({ default: m.HighlightReviewPage }))) import { Header } from './components/Header' @@ -104,6 +105,7 @@ function LanguageRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -157,6 +159,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/__tests__/tutor.test.ts b/apps/web/src/api/__tests__/tutor.test.ts new file mode 100644 index 00000000..26bb8a14 --- /dev/null +++ b/apps/web/src/api/__tests__/tutor.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { startTutorSession, sendTutorFeedback } from '../tutor' + +function mockOk(body: unknown) { + return vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify(body), + }) +} + +const SAMPLE = { + sessionId: 's1', + plan: [{ wordId: 'w1', word: 'foo', stage: 1, exerciseType: 'recognition', difficulty: 'easy', why: 'because' }], + rationale: 'plan', + readingNudge: 'read more', + runId: 'r1', +} + +describe('tutor api', () => { + beforeEach(() => vi.restoreAllMocks()) + afterEach(() => vi.unstubAllGlobals()) + + it('startTutorSession POSTs maxItems and returns parsed response', async () => { + const fetchMock = mockOk(SAMPLE) + vi.stubGlobal('fetch', fetchMock) + + const res = await startTutorSession(7) + + expect(res.sessionId).toBe('s1') + expect(res.plan).toHaveLength(1) + const [url, opts] = fetchMock.mock.calls[0] + expect(String(url)).toContain('/me/tutor/session') + expect(opts.method).toBe('POST') + expect(opts.credentials).toBe('include') + expect(JSON.parse(opts.body)).toEqual({ maxItems: 7 }) + }) + + it('startTutorSession sends empty body when maxItems omitted', async () => { + const fetchMock = mockOk(SAMPLE) + vi.stubGlobal('fetch', fetchMock) + + await startTutorSession() + + const [, opts] = fetchMock.mock.calls[0] + expect(JSON.parse(opts.body)).toEqual({}) + }) + + it('sendTutorFeedback POSTs results to the session feedback URL', async () => { + const fetchMock = mockOk({ ...SAMPLE, plan: [] }) + vi.stubGlobal('fetch', fetchMock) + + const results = [{ wordId: 'w1', correct: true, responseTimeMs: 1234 }] + const res = await sendTutorFeedback('s1', results) + + expect(res.plan).toHaveLength(0) + const [url, opts] = fetchMock.mock.calls[0] + expect(String(url)).toContain('/me/tutor/session/s1/feedback') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ results }) + }) + + it('rejects on a non-ok response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + text: async () => JSON.stringify({ error: 'no tutor' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(startTutorSession()).rejects.toThrow('no tutor') + }) +}) diff --git a/apps/web/src/api/tutor.ts b/apps/web/src/api/tutor.ts new file mode 100644 index 00000000..9f606e52 --- /dev/null +++ b/apps/web/src/api/tutor.ts @@ -0,0 +1,72 @@ +import { authFetch } from './client' + +// Learning Tutor agent (AI-Agent-2). The tutor PLANS what to study next over the learner's real SRS + +// reading state and hands off to the existing vocabulary-review flow. JSON (SSE deferred). The plan is held +// server-side in a session so the HITL re-plan turn survives across requests. + +// --- Types (mirror Contracts/Agents/TutorDtos.cs, camelCase via the API) --- + +/** + * One planned study item. The backend now ENRICHES each item with the full card payload (translation, + * definition, sentence, bookTitle, hint, distractors), so the UI renders cards 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 + 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 +} + +// --- API Functions --- + +/** Plan a new tutor session over the learner's current state. `maxItems` is optional (server-capped). */ +export async 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 async 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, + }) +} diff --git a/apps/web/src/components/vocabulary/TutorPlanView.tsx b/apps/web/src/components/vocabulary/TutorPlanView.tsx new file mode 100644 index 00000000..812b46ca --- /dev/null +++ b/apps/web/src/components/vocabulary/TutorPlanView.tsx @@ -0,0 +1,55 @@ +import type { TutorPlanItem } from '../../api/tutor' +import { exerciseLabel, exerciseBadgeClass } from './tutorLabels' + +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. +export function TutorPlanView({ rationale, plan, readingNudge, adjusted, t, onStart }: Props) { + return ( +
+
+ + {adjusted ? t('tutor.plan.adjustedLabel') : t('tutor.plan.rationaleLabel')} + +

{rationale}

+
+ +
    + {plan.map((item, i) => ( +
  1. + {i + 1} +
    +
    + {item.word} + + {exerciseLabel(item.exerciseType, t)} + + {item.difficulty} +
    +

    {item.why}

    +
    +
  2. + ))} +
+ + {readingNudge && ( +
+ 📖 +

{readingNudge}

+
+ )} + + +
+ ) +} diff --git a/apps/web/src/components/vocabulary/tutorLabels.ts b/apps/web/src/components/vocabulary/tutorLabels.ts new file mode 100644 index 00000000..66f001c0 --- /dev/null +++ b/apps/web/src/components/vocabulary/tutorLabels.ts @@ -0,0 +1,21 @@ +// Display helpers that tolerate untrusted LLM strings. `exerciseType` and `difficulty` come straight from +// the model — guard them so an unexpected value doesn't render a raw i18n key (`tutor.exercise.foo`) or +// blow up the layout. + +const KNOWN_EXERCISE_TYPES = new Set(['recognition', 'recall', 'context']) + +/** + * Label for an exercise type. For a known type we use the i18n key; for anything unexpected from the model + * we fall 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 types map to a styled badge variant; unknown types get a neutral default. */ +export function exerciseBadgeClass(exerciseType: string): string { + const variant = KNOWN_EXERCISE_TYPES.has(exerciseType) ? exerciseType : 'default' + return `tutor-badge tutor-badge--${variant}` +} diff --git a/apps/web/src/hooks/useTutorSession.test.ts b/apps/web/src/hooks/useTutorSession.test.ts new file mode 100644 index 00000000..f014b940 --- /dev/null +++ b/apps/web/src/hooks/useTutorSession.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' + +vi.mock('../api/tutor', () => ({ startTutorSession: vi.fn(), sendTutorFeedback: vi.fn() })) +vi.mock('../lib/dataEvents', () => ({ emitDataChange: vi.fn() })) + +import { startTutorSession, sendTutorFeedback, type TutorPlanItem } from '../api/tutor' +import { useTutorSession, buildPlanCard, buildQueue, isSessionComplete } from './useTutorSession' + +const mockStart = startTutorSession as unknown as ReturnType +const mockFeedback = sendTutorFeedback as unknown as ReturnType + +// Enriched plan item — self-sufficient, no vocab fetch needed. +const planItem = (id: string, word: string, over: Partial = {}): TutorPlanItem => ({ + wordId: id, word, stage: 1, exerciseType: 'recognition', difficulty: 'easy', why: `study ${word}`, + translation: `${word}-tr`, definition: null, sentence: `a ${word} sentence`, bookTitle: 'Some Book', + hint: null, distractors: [], ...over, +}) + +describe('tutor pure logic', () => { + it('buildPlanCard projects an enriched plan item onto a classic (context) card', () => { + const card = buildPlanCard(planItem('w1', 'foo')) + expect(card).toMatchObject({ + wordId: 'w1', word: 'foo', translation: 'foo-tr', reviewMode: 'context', + originalSentence: 'a foo sentence', bookTitle: 'Some Book', + }) + }) + + it('buildPlanCard tolerates missing optional fields (null, not undefined)', () => { + const card = buildPlanCard(planItem('w1', 'foo', { translation: undefined, sentence: undefined, bookTitle: undefined, hint: undefined })) + expect(card.translation).toBeNull() + expect(card.originalSentence).toBeNull() + expect(card.bookTitle).toBeNull() + expect(card.hint).toBeNull() + }) + + it('buildQueue keeps every enriched item (no join, nothing dropped)', () => { + const plan = [planItem('w1', 'a'), planItem('w2', 'b')] + const queue = buildQueue(plan) + expect(queue).toHaveLength(2) + expect(queue.map(q => q.item.wordId)).toEqual(['w1', 'w2']) + expect(queue[0].card.word).toBe('a') + }) + + it('isSessionComplete is true only for an empty plan', () => { + expect(isSessionComplete([])).toBe(true) + expect(isSessionComplete([planItem('w1', 'a')])).toBe(false) + }) +}) + +describe('useTutorSession flow', () => { + beforeEach(() => { + mockStart.mockReset() + mockFeedback.mockReset() + }) + + it('start with an empty plan → empty phase (not an error), no vocab fetch', async () => { + mockStart.mockResolvedValue({ sessionId: 's1', plan: [], rationale: 'r', readingNudge: 'read', runId: 'run1' }) + const { result } = renderHook(() => useTutorSession()) + + await act(async () => { await result.current.start() }) + + expect(result.current.phase).toBe('empty') + expect(result.current.readingNudge).toBe('read') + }) + + it('start → plan phase, queue built straight from the enriched plan', async () => { + mockStart.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w1', 'a'), planItem('w2', 'b')], + rationale: 'here is why', readingNudge: 'keep reading', runId: 'run1', + }) + + const { result } = renderHook(() => useTutorSession()) + await act(async () => { await result.current.start() }) + + expect(result.current.phase).toBe('plan') + expect(result.current.turn?.queue).toHaveLength(2) + expect(result.current.turn?.queue[0].card.translation).toBe('a-tr') + expect(result.current.turn?.rationale).toBe('here is why') + }) + + it('study → answers accumulate → empty re-plan ends in summary', async () => { + mockStart.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w1', 'a')], + rationale: 'r', readingNudge: 'nudge', runId: 'run1', + }) + mockFeedback.mockResolvedValue({ sessionId: 's1', plan: [], rationale: 'r2', readingNudge: 'final nudge', runId: 'run2' }) + + const { result } = renderHook(() => useTutorSession()) + await act(async () => { await result.current.start() }) + + act(() => { result.current.beginStudy() }) + expect(result.current.phase).toBe('study') + + await act(async () => { result.current.answer(true, 900) }) + + await waitFor(() => expect(result.current.phase).toBe('summary')) + expect(result.current.stats).toEqual({ studied: 1, correct: 1 }) + expect(result.current.readingNudge).toBe('final nudge') + expect(mockFeedback).toHaveBeenCalledWith('s1', [{ wordId: 'w1', correct: true, responseTimeMs: 900 }], expect.anything()) + }) + + it('non-empty re-plan continues with adjusted plan', async () => { + mockStart.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w1', 'a')], rationale: 'r1', readingNudge: 'n1', runId: 'run1', + }) + mockFeedback.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w2', 'b')], rationale: 'adjusted reasoning', readingNudge: 'n2', runId: 'run2', + }) + + const { result } = renderHook(() => useTutorSession()) + await act(async () => { await result.current.start() }) + act(() => { result.current.beginStudy() }) + await act(async () => { result.current.answer(false, 500) }) + + await waitFor(() => expect(result.current.phase).toBe('plan')) + expect(result.current.adjusted).toBe(true) + expect(result.current.turn?.rationale).toBe('adjusted reasoning') + expect(result.current.turn?.queue[0].item.wordId).toBe('w2') + }) + + it('client round cap: a server that never stops re-planning is forced to summary', async () => { + mockStart.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w1', 'a')], rationale: 'r', readingNudge: 'n', runId: 'run1', + }) + // Always hand back one more item — would loop forever without the client backstop. + mockFeedback.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w1', 'a')], rationale: 'again', readingNudge: 'n', runId: 'run2', + }) + + const { result } = renderHook(() => useTutorSession()) + await act(async () => { await result.current.start() }) + + // Drive enough rounds to exceed MAX_ROUNDS (8). Each round: study → answer → re-plan. + for (let i = 0; i < 10; i++) { + if (result.current.phase === 'summary') break + act(() => { result.current.beginStudy() }) + await act(async () => { result.current.answer(true, 100) }) + await waitFor(() => expect(['plan', 'summary']).toContain(result.current.phase)) + } + + expect(result.current.phase).toBe('summary') + expect(mockFeedback.mock.calls.length).toBeLessThanOrEqual(9) + }) + + it('start failure → error phase with planning origin', async () => { + mockStart.mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => useTutorSession()) + await act(async () => { await result.current.start() }) + expect(result.current.phase).toBe('error') + expect(result.current.error).toBe('boom') + expect(result.current.errorOrigin).toBe('planning') + }) + + it('feedback failure → retry re-submits SAME session results (no new session)', async () => { + mockStart.mockResolvedValue({ + sessionId: 's1', plan: [planItem('w1', 'a')], rationale: 'r', readingNudge: 'n', runId: 'run1', + }) + mockFeedback.mockRejectedValueOnce(new Error('network')) + + const { result } = renderHook(() => useTutorSession()) + await act(async () => { await result.current.start() }) + act(() => { result.current.beginStudy() }) + await act(async () => { result.current.answer(true, 700) }) + + await waitFor(() => expect(result.current.phase).toBe('error')) + expect(result.current.errorOrigin).toBe('feedback') + + // Retry should re-POST feedback for the SAME session, not call startTutorSession again. + mockFeedback.mockResolvedValueOnce({ sessionId: 's1', plan: [], rationale: 'r2', readingNudge: 'done', runId: 'run2' }) + await act(async () => { result.current.retry() }) + + await waitFor(() => expect(result.current.phase).toBe('summary')) + expect(mockStart).toHaveBeenCalledTimes(1) // only the initial plan + expect(mockFeedback).toHaveBeenCalledTimes(2) // failed + retried + expect(mockFeedback).toHaveBeenLastCalledWith('s1', [{ wordId: 'w1', correct: true, responseTimeMs: 700 }], expect.anything()) + }) +}) diff --git a/apps/web/src/hooks/useTutorSession.ts b/apps/web/src/hooks/useTutorSession.ts new file mode 100644 index 00000000..7930b653 --- /dev/null +++ b/apps/web/src/hooks/useTutorSession.ts @@ -0,0 +1,235 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { + startTutorSession, + sendTutorFeedback, + type TutorPlanItem, + type TutorSessionResponse, + type TutorFeedbackResult, +} from '../api/tutor' +import { type ReviewCardDto } from '../api/vocabulary' +import { emitDataChange } from '../lib/dataEvents' + +// Phase of the tutor flow the page renders. +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 + +/** + * Builds a classic-flashcard `ReviewCardDto` directly from an ENRICHED plan item. The backend already + * validated + enriched every item (translation/definition/sentence/bookTitle/hint/distractors), so there's + * no vocab fetch + join anymore — the plan item is self-sufficient. Pure projection, unit-tested. + */ +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 +} + +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 can re-send 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 + emitDataChange('vocabulary') + 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/web/src/locales/en.json b/apps/web/src/locales/en.json index d24eeab8..dce94864 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -1308,6 +1308,50 @@ "practicedToday": "Practiced today" } }, + "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" + }, + "study": { + "why": "Why this card" + }, + "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": "Browse books" + } + }, "breadcrumbs": { "home": "Home", "books": "Books", diff --git a/apps/web/src/pages/TutorSessionPage.tsx b/apps/web/src/pages/TutorSessionPage.tsx new file mode 100644 index 00000000..017534de --- /dev/null +++ b/apps/web/src/pages/TutorSessionPage.tsx @@ -0,0 +1,182 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import { useLanguage } from '../context/LanguageContext' +import { useTranslation } from '../hooks/useTranslation' +import { useTutorSession } from '../hooks/useTutorSession' +import { useTts } from '../hooks/useTts' +import { useSoundEffects } from '../hooks/useSoundEffects' +import { FlashCard } from '../components/vocabulary/FlashCard' +import { TutorPlanView } from '../components/vocabulary/TutorPlanView' +import { exerciseLabel, exerciseBadgeClass } from '../components/vocabulary/tutorLabels' +import { SeoHead } from '../components/SeoHead' +import { EmptyState } from '../components/EmptyState' + +// Smart session (Learning Tutor, AI-Agent-2). Layers the tutor's PLANNING + reasoning over the existing +// vocabulary-review card. Flow: plan view (the showcase) → study (reuse FlashCard) → feedback re-plan → summary. +export function TutorSessionPage() { + const { user } = useAuth() + const { language } = useLanguage() + const { t } = useTranslation() + const navigate = useNavigate() + const tutor = useTutorSession() + const { speak } = useTts() + const { play: playSound } = useSoundEffects() + + const handleSpeak = (text: string) => speak(text, language) + const goBack = () => navigate(`/${language}/vocabulary`) + + useEffect(() => { + if (user) tutor.start() + }, [user]) // eslint-disable-line react-hooks/exhaustive-deps + + if (!user) { + return ( +
+ +
+ ) + } + + if (tutor.phase === 'planning' || tutor.phase === 'idle') { + return ( +
+ +
+
+

{t('tutor.planning')}

+
+
+ ) + } + + if (tutor.phase === 'error') { + return ( +
+ tutor.retry()} + /> +
+ ) + } + + if (tutor.phase === 'empty') { + return ( +
+ + +
+ ) + } + + if (tutor.phase === 'summary') { + const { studied, correct } = tutor.stats + const rate = studied > 0 ? Math.round((correct / studied) * 100) : 0 + return ( +
+ +
+
{t('tutor.summary.done')}
+
+
+ {studied} + {t('tutor.summary.studied')} +
+
+ {rate}% + {t('tutor.summary.accuracy')} +
+
+ {tutor.readingNudge && ( +
+ 📖 +

{tutor.readingNudge}

+
+ )} + +
+
+ ) + } + + // plan + study phases need an active turn + if (!tutor.turn) return null + + if (tutor.phase === 'plan') { + return ( +
+ + {tutor.adjusted && ( +
{t('tutor.plan.adjustedNote')}
+ )} + +
+ ) + } + + // study phase + const entry = tutor.currentEntry + if (!entry) return null + const queue = tutor.turn.queue + + const handleAnswer = (isCorrect: boolean, responseTimeMs: number) => { + playSound(isCorrect ? 'correct' : 'wrong') + tutor.answer(isCorrect, responseTimeMs) + } + + return ( +
+ +
+
+
+
+ + {tutor.currentIndex + 1} / {queue.length} + +
+ +
+ + {exerciseLabel(entry.item.exerciseType, t)} + + {entry.item.why} +
+ + playSound('flip')} + t={t} + /> +
+ ) +} diff --git a/apps/web/src/pages/VocabularyPage.tsx b/apps/web/src/pages/VocabularyPage.tsx index 03040d36..b39f68c2 100644 --- a/apps/web/src/pages/VocabularyPage.tsx +++ b/apps/web/src/pages/VocabularyPage.tsx @@ -337,12 +337,22 @@ export function VocabularyPage() {
- +
+ + +
)} diff --git a/apps/web/src/styles/vocabulary.css b/apps/web/src/styles/vocabulary.css index 0615ba28..9a282a5e 100644 --- a/apps/web/src/styles/vocabulary.css +++ b/apps/web/src/styles/vocabulary.css @@ -2269,3 +2269,266 @@ .vocab-cluster-card__start, .vocab-cluster-card__dismiss { flex: 1; } } + +/* --------------------------------------------------------------------------- + Learning Tutor (Smart session, AI-Agent-2) + --------------------------------------------------------------------------- */ + +/* Entry button on the vocabulary page */ +.practice-page__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.practice-page__tutor-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.875rem 1.5rem; + background: var(--color-surface); + color: var(--color-primary); + border: 1.5px solid var(--color-primary); + border-radius: 10px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.practice-page__tutor-btn:hover { opacity: 0.85; } +.practice-page__tutor-icon { font-size: 1.1rem; } + +/* Tutor page shell */ +.tutor-page { max-width: 720px; margin: 0 auto; } + +/* Planning state */ +.tutor-planning { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 4rem 1rem; + color: var(--color-text-secondary); +} + +.tutor-planning__spinner { + width: 32px; + height: 32px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: tutor-spin 0.8s linear infinite; +} + +@keyframes tutor-spin { to { transform: rotate(360deg); } } + +.tutor-planning__text { font-size: 1rem; } + +/* Plan view (the showcase) */ +.tutor-plan { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.tutor-plan__rationale { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 4px solid var(--color-primary); + border-radius: 12px; + padding: 1.1rem 1.25rem; +} + +.tutor-plan__rationale-label { + display: block; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; + color: var(--color-primary); + margin-bottom: 0.4rem; +} + +.tutor-plan__rationale-text { + font-size: 1.02rem; + line-height: 1.5; + color: var(--color-text); + margin: 0; +} + +.tutor-plan__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tutor-plan__item { + display: flex; + gap: 0.85rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 0.9rem 1rem; +} + +.tutor-plan__item-index { + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-bg); + font-size: 0.85rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.tutor-plan__item-body { flex: 1; min-width: 0; } + +.tutor-plan__item-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.tutor-plan__item-word { font-size: 1.05rem; font-weight: 700; color: var(--color-text); } + +.tutor-plan__item-difficulty { + font-size: 0.75rem; + color: var(--color-text-secondary); + text-transform: capitalize; +} + +.tutor-plan__item-why { + margin: 0; + font-size: 0.9rem; + line-height: 1.45; + color: var(--color-text-secondary); +} + +/* Exercise-type badge */ +.tutor-badge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.15rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); +} + +/* Line-clamp utility for untrusted LLM free-text (rationale / why / nudge / difficulty). Caps height so an + over-long model string can't blow up the layout; React already escapes the text (no XSS). */ +.tutor-clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} +.tutor-clamp--1 { -webkit-line-clamp: 1; } +.tutor-clamp--3 { -webkit-line-clamp: 3; } +.tutor-clamp--4 { -webkit-line-clamp: 4; } + +.tutor-badge--default { background: var(--color-surface); color: var(--color-text-secondary); } +.tutor-badge--recognition { background: rgba(96, 165, 250, 0.14); color: #2563eb; border-color: transparent; } +.tutor-badge--recall { background: rgba(168, 85, 247, 0.14); color: #9333ea; border-color: transparent; } +.tutor-badge--context { background: rgba(34, 197, 94, 0.14); color: #16a34a; border-color: transparent; } + +/* Reading nudge (the thesis) */ +.tutor-plan__nudge { + display: flex; + gap: 0.6rem; + align-items: flex-start; + background: rgba(201, 162, 39, 0.08); + border: 1px dashed var(--color-primary); + border-radius: 12px; + padding: 0.9rem 1.1rem; +} + +.tutor-plan__nudge-icon { font-size: 1.1rem; line-height: 1.4; } +.tutor-plan__nudge-text { margin: 0; font-size: 0.95rem; line-height: 1.5; color: var(--color-text); } + +.tutor-plan__start { + align-self: stretch; + padding: 0.875rem 1.5rem; + background: var(--color-primary); + color: var(--color-bg); + border: none; + border-radius: 10px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.tutor-plan__start:hover { opacity: 0.9; } + +.tutor-adjusted-note { + background: rgba(96, 165, 250, 0.12); + color: var(--color-text); + border-radius: 10px; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + font-size: 0.92rem; +} + +/* Study phase: the per-item "why" above the card */ +.tutor-study__why { + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0.75rem 0 1rem; + flex-wrap: wrap; +} + +.tutor-study__why-text { + font-size: 0.88rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +/* Summary */ +.tutor-summary { + display: flex; + flex-direction: column; + gap: 1.25rem; + align-items: stretch; +} + +.tutor-summary__banner { + text-align: center; + font-size: 1.3rem; + font-weight: 700; + color: var(--color-primary); + padding: 1rem; +} + +.tutor-summary__stats { + display: flex; + justify-content: center; + gap: 2.5rem; +} + +.tutor-summary__stat { display: flex; flex-direction: column; align-items: center; } +.tutor-summary__stat-value { font-size: 2rem; font-weight: 700; color: var(--color-text); } +.tutor-summary__stat-label { font-size: 0.8rem; color: var(--color-text-secondary); } +.tutor-summary__nudge { margin-top: 0.5rem; } + +.tutor-link-btn { + background: none; + border: none; + color: var(--color-primary); + font-size: 0.95rem; + cursor: pointer; + margin-top: 1rem; + text-decoration: underline; +} diff --git a/backend/src/Api/Endpoints/TutorEndpoints.cs b/backend/src/Api/Endpoints/TutorEndpoints.cs index 247c9c82..1b10ca5c 100644 --- a/backend/src/Api/Endpoints/TutorEndpoints.cs +++ b/backend/src/Api/Endpoints/TutorEndpoints.cs @@ -23,6 +23,13 @@ public static class TutorEndpoints /// Default session size when the client doesn't ask for one — a sane, non-drilling session. public const int DefaultMaxItems = 5; + /// + /// Hard cap on re-plan turns (HITL). Each feedback turn increments TurnCount; once it reaches this, + /// the session is completed (empty plan) instead of re-planning, so a persistently-wrong card (or an + /// over-eager planner) can't re-surface forever. The client already treats an empty plan as "complete". + /// + public const int MaxTurns = 6; + public static void MapTutorEndpoints(this WebApplication app) { var group = app.MapGroup("/me/tutor").WithTags("Agents").RequireRateLimiting("tutor"); @@ -66,7 +73,8 @@ private static async Task StartSession( db.TutorSessions.Add(session); await db.SaveChangesAsync(ct); - return Results.Ok(ToResponse(session.Id, plan, runId)); + var cards = await LoadPlanCardsAsync(db, plan, userId.Value, ct); + return Results.Ok(ToResponse(session.Id, plan, cards, runId)); } // POST /me/tutor/session/{id}/feedback — re-plan the remainder given the learner's results. @@ -97,6 +105,20 @@ private static async Task SubmitFeedback( .Where(r => priorPlanIds.Contains(r.WordId)) .Select(r => new TutorFeedbackItem(r.WordId, r.Correct, Math.Max(0, r.ResponseTimeMs))) .ToList(); + + // Turn cap: once the session has run MaxTurns re-plans, complete it instead of planning again so a + // persistently-wrong card can't loop forever. Returns an empty plan (the client's "session complete" + // signal) — short-circuits before any LLM call. + if (ReachedTurnCap(session.TurnCount)) + { + var done = TutorPlan.Empty("Session complete — you've reached the turn limit for this round. Keep reading."); + session.PlanJson = done.ToPlanJson(); + session.Status = TutorSession.StatusCompleted; + session.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + return Results.Ok(ToResponse(session.Id, done, [], Guid.Empty)); + } + var maxItems = CountPlanItems(session.PlanJson, fallback: DefaultMaxItems); var input = new TutorInput(maxItems, feedback); @@ -116,7 +138,8 @@ private static async Task SubmitFeedback( session.Status = TutorSession.StatusCompleted; await db.SaveChangesAsync(ct); - return Results.Ok(ToResponse(session.Id, plan, runId)); + var cards = await LoadPlanCardsAsync(db, plan, userId.Value, ct); + return Results.Ok(ToResponse(session.Id, plan, cards, runId)); } /// @@ -164,15 +187,69 @@ record = AgentRunRecordFactory.Failed(runId, TutorAgent.AgentName, userId, editi return (outcome.Plan, runId, null); } - private static TutorSessionResponse ToResponse(Guid sessionId, TutorPlan plan, Guid runId) => + /// + /// Loads the REAL VocabularyWord rows backing a plan, scoped to the caller. This is the render-field + /// source for and a natural anti-hallucination re-check: only the caller's + /// own cards survive (the UserId filter), and any planned id that isn't one of them is later dropped. + /// + private static async Task> LoadPlanCardsAsync( + IAppDbContext db, TutorPlan plan, Guid userId, CancellationToken ct) + { + var ids = plan.Items.Select(i => i.WordId).Distinct().ToList(); + if (ids.Count == 0) return []; + return await db.VocabularyWords + .Where(v => ids.Contains(v.Id) && v.UserId == userId) + .ToListAsync(ct); + } + + private static TutorSessionResponse ToResponse( + Guid sessionId, TutorPlan plan, IReadOnlyList cards, Guid runId) => new( sessionId, - plan.Items.Select(i => new TutorPlanItemDto( - i.WordId, i.Word, i.Stage, i.ExerciseType, i.Difficulty, i.Why)).ToList(), + EnrichPlanItems(plan, cards), plan.Rationale, plan.ReadingNudge, runId); + /// + /// Projects each plan item to its DTO, enriching the render fields from the caller's matching + /// VocabularyWord row (keyed on the already-validated ). An item + /// with no matching row is DROPPED — the enrichment never introduces an unvalidated id and never emits a + /// card with null render fields for a phantom id. Distractors are parsed from the same JSON array column + /// the vocabulary-review flow reads (["w1","w2",...]); empty list when absent or malformed. + /// + internal static List EnrichPlanItems(TutorPlan plan, IReadOnlyList cards) + { + var byId = cards.ToDictionary(c => c.Id); + var result = new List(plan.Items.Count); + foreach (var i in plan.Items) + { + if (!byId.TryGetValue(i.WordId, out var card)) + continue; // planned id isn't one of the caller's cards → drop (anti-hallucination re-check) + result.Add(new TutorPlanItemDto( + i.WordId, i.Word, i.Stage, i.ExerciseType, i.Difficulty, i.Why, + card.Translation, card.Definition, card.Sentence, card.BookTitle, card.Hint, + ParseDistractors(card.Distractors))); + } + return result; + } + + /// + /// Parses the VocabularyWord.Distractors JSON array (["w1","w2",...]) into a list, mirroring + /// the review flow's tolerance: missing/empty/malformed → empty list. (Kept minimal — the MC-option + /// shuffling/filtering lives in the review card builder; the tutor hands raw distractors to the client.) + /// + internal static IReadOnlyList ParseDistractors(string? json) + { + if (string.IsNullOrEmpty(json)) return []; + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list?.Where(w => !string.IsNullOrWhiteSpace(w)).ToList() ?? []; + } + catch { return []; } + } + /// /// Deterministic backstop (not LLM-trusted): removes any plan item whose card the learner just answered /// correct:true in this feedback turn, so a just-passed card can never be re-surfaced regardless of @@ -211,6 +288,12 @@ internal static HashSet PriorPlanWordIds(string planJson) return ids; } + /// + /// True once a session has reached the re-plan turn cap (FIX 2) — the feedback path then completes the + /// session with an empty plan instead of planning again, so a persistently-wrong card can't loop forever. + /// + internal static bool ReachedTurnCap(int turnCount) => turnCount >= MaxTurns; + /// Counts the items in a persisted plan (the prior session size) to keep re-plan turns the same length. internal static int CountPlanItems(string planJson, int fallback) { diff --git a/backend/src/Contracts/Agents/TutorDtos.cs b/backend/src/Contracts/Agents/TutorDtos.cs index 10267154..7cc5ae0b 100644 --- a/backend/src/Contracts/Agents/TutorDtos.cs +++ b/backend/src/Contracts/Agents/TutorDtos.cs @@ -13,8 +13,14 @@ public record TutorFeedbackRequest(IReadOnlyList Results /// One planned study item in the Tutor response. + reference a REAL /// vocab card (re-projected from a tool result — never invented). is calibrated to /// the card's SRS (recognition / recall / context), to stage + -/// accuracy, and is the per-item reasoning. The client hands these off to the existing -/// vocabulary-review flow. +/// accuracy, and is the per-item reasoning. +/// +/// The render fields (, , , +/// , , ) are enriched server-side from the +/// caller's REAL VocabularyWord row (keyed on the already-validated ) so the client +/// can build the same card the vocabulary-review flow renders without a fragile re-fetch+join. An item whose +/// id no longer maps to one of the caller's cards is dropped, not emitted with null render fields. +/// /// public record TutorPlanItemDto( Guid WordId, @@ -22,7 +28,13 @@ public record TutorPlanItemDto( int Stage, string ExerciseType, string Difficulty, - string Why); + string Why, + string? Translation, + string? Definition, + string? Sentence, + string? BookTitle, + string? Hint, + IReadOnlyList Distractors); /// /// The Tutor agent's response: the persisted (carry it to the feedback endpoint), the diff --git a/tests/TextStack.UnitTests/TutorEndpointsReplanTests.cs b/tests/TextStack.UnitTests/TutorEndpointsReplanTests.cs index 77386aef..c8407864 100644 --- a/tests/TextStack.UnitTests/TutorEndpointsReplanTests.cs +++ b/tests/TextStack.UnitTests/TutorEndpointsReplanTests.cs @@ -1,5 +1,6 @@ using Api.Endpoints; using Application.Agents; +using Domain.Entities; namespace TextStack.UnitTests; @@ -98,4 +99,96 @@ public void DropPassedCards_MissedCard_IsKept() Assert.Single(result.Items); // a missed card is allowed to re-surface } + + // ---- FIX 1: enrich plan items from the caller's REAL vocab rows --------------------------------- + + private static VocabularyWord Card(Guid id, string? distractorsJson = null) => new() + { + Id = id, + UserId = Guid.NewGuid(), + SiteId = Guid.NewGuid(), + Word = "alacrity", + Language = "en", + Translation = "жвавість", + Definition = "brisk and cheerful readiness", + Sentence = "She accepted with alacrity.", + BookTitle = "Pride and Prejudice", + Hint = "eager willingness", + Distractors = distractorsJson, + }; + + [Fact] + public void EnrichPlanItems_MatchingCard_PopulatesRenderFieldsFromRow() + { + var plan = PlanOf(A); + var rows = new[] { Card(A, "[\"sloth\",\"reluctance\",\"delay\"]") }; + + var dtos = TutorEndpoints.EnrichPlanItems(plan, rows); + + var dto = Assert.Single(dtos); + Assert.Equal(A, dto.WordId); + Assert.Equal("жвавість", dto.Translation); + Assert.Equal("brisk and cheerful readiness", dto.Definition); + Assert.Equal("She accepted with alacrity.", dto.Sentence); + Assert.Equal("Pride and Prejudice", dto.BookTitle); + Assert.Equal("eager willingness", dto.Hint); + Assert.Equal(new[] { "sloth", "reluctance", "delay" }, dto.Distractors); + // The planning fields are preserved alongside the render fields. + Assert.Equal(TutorPlanItem.ExerciseRecognition, dto.ExerciseType); + Assert.Equal("why", dto.Why); + } + + [Fact] + public void EnrichPlanItems_PlanIdNotInCallerCards_IsDroppedNotNullEnriched() + { + // Plan has A and B; only A is one of the caller's real cards. B must be DROPPED, not emitted with nulls. + var plan = PlanOf(A, B); + var rows = new[] { Card(A) }; + + var dtos = TutorEndpoints.EnrichPlanItems(plan, rows); + + var dto = Assert.Single(dtos); + Assert.Equal(A, dto.WordId); + Assert.DoesNotContain(dtos, d => d.WordId == B); // anti-hallucination re-check holds + } + + [Fact] + public void EnrichPlanItems_NoMatchingCards_ReturnsEmpty() + { + var dtos = TutorEndpoints.EnrichPlanItems(PlanOf(A), cards: []); + Assert.Empty(dtos); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not json")] + [InlineData("[]")] + public void ParseDistractors_MissingOrMalformed_ReturnsEmptyList(string? json) + { + Assert.Empty(TutorEndpoints.ParseDistractors(json)); + } + + [Fact] + public void ParseDistractors_ValidArray_DropsBlankEntries() + { + var result = TutorEndpoints.ParseDistractors("[\"a\",\"\",\" \",\"b\"]"); + Assert.Equal(new[] { "a", "b" }, result); + } + + // ---- FIX 2: re-plan turns are capped so a session can't loop forever ---------------------------- + + [Fact] + public void ReachedTurnCap_BelowCap_IsFalse() + { + Assert.False(TutorEndpoints.ReachedTurnCap(TutorEndpoints.MaxTurns - 1)); + } + + [Fact] + public void ReachedTurnCap_AtCap_IsTrue() + { + // At MaxTurns the feedback path completes the session (empty plan) instead of re-planning. + Assert.True(TutorEndpoints.ReachedTurnCap(TutorEndpoints.MaxTurns)); + Assert.True(TutorEndpoints.ReachedTurnCap(TutorEndpoints.MaxTurns + 1)); + } }