# Project export: Fluently

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Fluently transcribes your reading in real time, aligns it to the target passage, and classifies every deviation against the syntactic structure of the text with pattern recognition.
- Devpost: https://devpost.com/software/fluently-rm76lw
- GitHub: https://github.com/appleorange/fluently
- Video: https://www.youtube.com/embed/64zQlaumeMw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Sabella Han (37 commits), Claude Sonnet 4.6 (7 commits), shannon-xiao (7 commits)

## Devpost submission (written by the team)

### Inspiration

Growing up, we had a family friend on the autism spectrum who struggled with reading and speaking aloud. We watched how much early intervention mattered for kids like him, and how much of that intervention depended on having access to the right specialists at the right time. That gap stuck with us. When we started building Fluently, we wanted to create something that could do what a speaking specialist does. We aren't aiming to replace the human connection, but instead, make that level of precision accessible to every family, regardless of income or location. The earlier a reading difficulty is caught and understood, the better the outcome. Fluently is built on that belief.

### What it does

Fluently listens to your child read aloud and does what a reading/speaking specialist does, in real time. A child reads a passage out loud. Every word lights up on screen: green if correct, red if an error, yellow if they hesitated. At the end of 60 seconds, Fluently produces a report that doesn't just count mistakes but explains what they mean. Is this a decoding issue or a phrasing fluency issue? These have different causes and different interventions, and most reading tools can't tell the difference. A 2D PassageMap lets you navigate material by complexity and register, with Claude generating fresh passages on demand. After each session, Redis vector search finds the optimal next passage based on where errors were concentrated, moving the reader harder in exactly the right dimension. Over multiple sessions, Fluently builds a longitudinal model of the reader's error patterns, shifting from a snapshot of today to a genuine reading profile that gets sharper every session.

### How we built it

Part 1: Deterministic pipeline The scoring engine is fully deterministic and AI-free. Deepgram Nova-3 streams word objects with start, duration, and confidence fields. A custom Levenshtein alignment function matches the transcript against the expected passage word-by-word, classifying each word as correct, substitution, omission, insertion, hesitation (\(> 500\text{ms}\) pause), or acoustically uncertain (confidence \(< 0.75\), excluded from all error metrics to avoid penalizing accent variation). A separate metrics pass computes WCPM benchmarked against DIBELS 8th Edition grade-level thresholds (Grade 2: \(\geq 125\), Grade 4: \(\geq 141\), Grade 6: \(\geq 135\), accuracy threshold \(\geq 96\%\)), pause placement using compromise.js to identify syntactic boundaries in the target text, and self-correction rate as its own positive signal separate from the error taxonomy. Part 2: AI layer Claude receives a structured JSON object (never raw audio or transcript) and returns a plain-language report calibrated to the reader's DIBELS tier (intensive: at risk, strategic: some risk, core: on track), the passage's register (informal passages don't penalize contractions or casual phrasing), and any persisting error patterns across prior sessions. The prompt runs in one of three modes: snapshot (first session), comparison (second session), or pattern-recognition (third session onward), each with distinct framing language. In pattern-recognition mode, Claude receives a full markdown table of historical metrics and is explicitly instructed to re-read every number from the table rather than recall from context. This eliminated hallucinated historical values during testing. Part 3: Redis AI integration Every session stores a 5-dimensional skill vector $$\mathbf{v} = [\text{complexityHandling},\ \text{registerHandling},\ \text{wcpmPercentile},\ \text{pausePlacementScore},\ \text{selfCorrectionRate}]$$ and full error metrics in Redis. After each session, computeNextTarget() identifies the weakest map-axis dimension (complexity or register only, since non-map dimensions like WCPM are addressed through Claude's exercise recommendations rather than passage movement) and computes the optimal next position in the 2D skill space. The target only escalates on an advance recommendation. On retry, the position stays fixed so the reader consolidates at the same level rather than compounding difficulty. A KNN search (FT.SEARCH) finds the nearest existing passage. If no close match exists within distance \(0.1\), Claude auto-generates a fresh passage at the exact target coordinates and stores it in Redis, growing the library organically with every session. The full reader history is fetched from Redis before every Claude call, enabling longitudinal pattern recognition across sessions. Part 4: PassageMap Instead of a grade picker, a draggable 2D SVG canvas lets users place a pin anywhere across the full K–adult complexity and casual–formal register space. Each pin placement calls Claude to generate a fresh ~70-word passage at those exact coordinates. After a session, a dashed blue arrow on the map shows where the recommendation moves the reader next, visually grounding the concept of "harder in the right dimension" in something a parent or child can immediately understand.

### Challenges we ran into

Accent fairness A child who pronounces "th" as "d" should not have that counted as an error. We implemented confidence score filtering and switched to accent-agnostic English recognition, then found and fixed a subtle bug where uncertain words were excluded from error counts but still dragging down the accuracy denominator silently, penalizing the reader anyway through a different metric. Longitudinal prompt hallucination In early testing, Claude would misstate historical WCPM values when given prior session data as prose. Switching to a structured markdown table with an explicit instruction to re-read every number from the table rather than recall from context eliminated the issue entirely. Redis vector search projection When running KNN search against our passage index, the query was returning passage identifiers and titles as literal undefined strings. The issue was a subtle mismatch between which fields Redis indexes for search and which fields it actually returns in query results.

### Accomplishments we're proud of

A fully deterministic scoring pipeline where Claude interprets but never detects Accent fairness built into the confidence filtering layer so no child is penalized for how they speak Redis powering genuine vector search across a 2D pedagogical skill space, not just caching Longitudinal error tracking that shifts Claude's diagnostic language after three sessions

### What we learned

The most powerful thing you can do with an LLM is constrain what it has to guess. Every time we moved a computation out of Claude and into a deterministic function, the output got more reliable and the AI layer got more useful. Real equity has to be designed into the architecture, not added as an afterthought. Accent fairness required deliberate decisions at the data layer, not just the UI. And longitudinal context changes everything: a system that remembers is fundamentally different from one that scores.

### What's next

Expanding to ESL adult learners with register-specific passage sets calibrated to workplace and academic English Support for speech therapy use cases including fluency disorders, apraxia, and progressive speech conditions like Huntington's disease, where tracking subtle degradation in prosody and phrasing over time could serve as an early clinical signal Difficulty regression on retry: backing off in the weak dimension before re-attempting, which is what reading specialists actually do

## README (from the GitHub repository)

# Fluently

Oral reading fluency assessment tool. A child reads a passage aloud, Deepgram transcribes with word-level timestamps, a deterministic Levenshtein alignment pipeline scores the reading against the expected passage, and Claude generates a plain-language diagnostic report.

Fluently also tracks a reader's skill profile across sessions in Redis (vector search over an AI-generated passage library) to recommend the next passage's difficulty and register, and to give Claude longitudinal context ("this is the student's 3rd session — has phrasing improved?") instead of grading every session in isolation.

## Prerequisites

- Node.js 18+
- **Redis Stack** (not plain Redis — vector search needs the RediSearch module, which plain Redis doesn't include)
- A [Deepgram](https://console.deepgram.com) API key
- An [Anthropic](https://console.anthropic.com) API key

### Installing Redis Stack (macOS)

```bash
brew tap redis-stack/redis-stack
brew install --cask redis-stack-server
```

Start it manually before running the app (it's a cask, so `brew services` doesn't manage it):

```bash
redis-stack-server
```

Leave that running in its own terminal tab. It listens on `redis://localhost:6379` by default.

For other platforms, see [redis.io/docs/install/install-stack](https://redis.io/docs/install/install-stack/) — or point `REDIS_URL` at a hosted Redis Cloud database (free tier supports RediSearch) if you don't want to run it locally.

## Setup

```bash
# 1. Install dependencies
npm install

# 2. Add API keys
cp .env.example .env.local
# Fill in DEEPGRAM_API_KEY, ANTHROPIC_API_KEY, and REDIS_URL in .env.local
# (REDIS_URL=redis://localhost:6379 if you're running Redis Stack locally per above)

# 3. Make sure redis-stack-server is running (see Prerequisites)

# 4. Run dev server
npm run dev
```

Then open [http://localhost:3000](http://localhost:3000).

## Scripts

| Command | What it does |
|---|---|
| `npm run dev` | Start the Next.js dev server |
| `npm run build` | Production build |
| `npm run start` | Run the production build |
| `npm run lint` | ESLint |

## Pages

- `/` — landing page
- `/practice` — the core flow: pick a passage (drag a point on the complexity/register map, or read an existing one), record yourself reading it, get a diagnostic report and a recommended next passage
- `/progress` — longitudinal view of a reader's session history

## Architecture

See `docs/architecture.md` for full data flow, and `docs/ROADMAP.md` for current build status vs. what's planned.

**The pipeline:**
1. Deepgram streams word-level transcription with timestamps
2. Levenshtein alignment scores every word (correct / substitution / omission / insertion / uncertain)
3. Metrics computation derives WCPM, error counts, pause placement, self-corrections
4. A skill vector (complexity handling, register handling, WCPM, pause placement, self-correction) is computed per session and stored in Redis
5. Redis vector search (`FT.CREATE`/`FT.SEARCH`) matches the reader's next-best passage target against a library of AI-generated passages, or generates a new one if nothing close exists
6. Claude receives a structured JSON object (metrics + DIBELS tier + prior-session history table) and generates the diagnostic report

Claude never sees raw audio or transcript — only structured data from the deterministic pipeline.

## Tech Stack

- Next.js 14 (App Router), TypeScript, Tailwind CSS
- Deepgram SDK (streaming, word timestamps)
- Anthropic SDK (diagnostic report generation, passage generation)
- Redis Stack (`redis` npm package) — vector search over the passage library, per-reader session history
- compromise.js (syntactic boundary detection for pause placement)
- gsap (animated passage-map dot grid)

## Hackathon — UC Berkeley AI Hackathon 2026
Track: Ddoski's World
Sponsors: Deepgram, Anthropic, Redis


## Detected evidence (automated analysis)

Indexed codebase: 31 recognized source files, 145 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (36 of 36)

```
.env.example
.gitignore
architecture
next-env.d.ts
next.config.js
package.json
postcss.config.js
README.md
src/app/api/deepgram-token/route.ts
src/app/api/diagnose/route.ts
src/app/api/generate-passage/route.ts
src/app/api/session/route.ts
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/app/practice/page.tsx
src/app/progress/page.tsx
src/components/AudioRecorder.tsx
src/components/DiagnosticReport.tsx
src/components/DotGrid.css
src/components/DotGrid.tsx
src/components/LoadingDots.tsx
src/components/MetricsDashboard.tsx
src/components/Nav.tsx
src/components/PassageDisplay.tsx
src/components/PassageMap.tsx
src/lib/alignment.ts
src/lib/deepgram.ts
src/lib/generatePassage.ts
src/lib/metrics.ts
src/lib/passageVectors.ts
src/lib/redis.ts
src/lib/sessionVector.ts
src/lib/types.ts
tailwind.config.ts
tsconfig.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.20.0, @deepgram/sdk@^3.0.0, @types/diff-match-patch@^1.0.36, @types/node@^20, @types/react@^18, @types/react-dom@^18, autoprefixer@^10.0.1, compromise@^14.0.0, diff-match-patch@^1.0.5, eslint@^8, eslint-config-next@14.2.0, gsap@^3.15.0, next@14.2.0, postcss@^8, react@^18, react-dom@^18, redis@^6.0.0, tailwindcss@^3.3.0, typescript@^5

### Recent commits (newest first)

- Add files via upload
- Rename Diagram Improvement Suggestions.png to architecture
- Add files via upload
- chore: remove internal planning docs from the public repo
- feat: redesign practice page layout and recording state
- feat: replace all loading animations with ellipsis dots loader
- feat: remove reports/resources pages, clean up PassageMap and result UI
- fix: improve retry-same-difficulty messaging for WCPM and phrasing
- feat: UI polish — page transitions, gradient, waveform, step tracker, copy update
- feat: animated dot-grid PassageMap with frozen click indicator
- merge: resolve divergent recommendation-logic changes
- fix: self-correction detection, weakest-dimension bias, map dot visibility
- feat: DIBELS/IEP-aligned recommendation logic + home page learn more expand
- fix: wire the PassageMap recommendation arrow into the results view
- feat: real session data on home/progress pages, passage markup in results
- docs: update roadmap for accent fairness work, fix stale confidence threshold
- feat: accent fairness via confidence filtering and language param update
- feat: passage-vector KNN search, auto-generation, and next-passage UI
- feat: Redis longitudinal tracking + homepage nav scaffold
- feat: self-correction signal, edge-case fixes, confidence-flagging extension

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "fluently",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.20.0",
    "@deepgram/sdk": "^3.0.0",
    "compromise": "^14.0.0",
    "diff-match-patch": "^1.0.5",
    "gsap": "^3.15.0",
    "next": "14.2.0",
    "react": "^18",
    "react-dom": "^18",
    "redis": "^6.0.0"
  },
  "devDependencies": {
    "@types/diff-match-patch": "^1.0.36",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.2.0",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "typescript": "^5"
  }
}

```

### src/app/layout.tsx

```typescript
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Nav from '@/components/Nav'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Fluently',
  description: 'Oral reading fluency assessment',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Nav />
        {children}
      </body>
    </html>
  )
}

```

### src/app/page.tsx

```typescript
'use client'

import Link from 'next/link'
import { useEffect, useState, type ReactNode } from 'react'
import LoadingDots from '@/components/LoadingDots'

const READER_ID_KEY = 'fluently-reader-id'

type TimeFilter = 'This Hour' | 'This Week' | 'This Month' | 'Last 3'

interface SessionSummary {
  sessionId: string
  timestamp: number
  passageId: string
  passageTitle?: string
  passageGrade: number
  metrics: {
    wcpm: number
    accuracy: number
    durationSeconds: number
  }
}

// ─── Hero illustration ──────────────────────────────────────────────────────

function HeroIllustration() {
  const barHeights = [6, 12, 20, 14, 24, 16, 28, 10, 22, 14, 30, 10, 18, 8, 14, 22, 16, 10, 20, 26, 12, 18, 10, 16]
  return (
    <div className="relative flex items-center justify-center h-72 select-none">
      <div className="absolute w-64 h-64 rounded-full bg-blue-50" />
      <div className="relative bg-white rounded-2xl shadow-lg px-8 py-7 w-56 z-10">
        <div className="flex justify-center mb-5 relative">
          <div className="w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center">
            <svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
              <path d="M6 2a2 2 0 00-2 2v16l8-2.5L20 20V4a2 2 0 00-2-2H6zm6 13.5L6 17.5V4h12v13.5l-6-2z" />
            </svg>
          </div>
          <div className="absolute -top-1 -right-3 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center shadow">
            <svg className="w-3.5 h-3.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden="true">
              <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
            </svg>
          </div>
        </div>
        <div className="space-y-2">
          <div className="h-1.5 bg-slate-100 rounded-full w-full" />
          <div className="h-1.5 bg-slate-100 rounded-full w-4/5" />
          <div className="h-1.5 bg-slate-100 rounded-full w-3/4" />
        </div>
      </div>
      <div className="absolute bottom-6 left-1/2 -translate-x-1/2 w-60 bg-white rounded-xl shadow px-5 py-3 z-10">
        <div className="flex items-center justify-center gap-0.5 h-8">
          {barHeights.map((h, i) => (
            <div key={i} className="w-1.5 bg-blue-400 rounded-full" style={{ height: h }} />
          ))}
        </div>
      </div>
    </div>
  )
}

// ─── Feature card ────────────────────────────────────────────────────────────

interface FeatureCardProps {
  icon: ReactNode
  iconBg: string
  title: string
  description: string
}

function FeatureCard({ icon, iconBg, title, description }: FeatureCardProps) {
  return (
    <div className="flex flex-col items-start gap-3">
      <div className={`w-10 h-10 rounded-xl flex items-center justify-center ${iconBg}`}>
        {icon}
      </div>
      <div>
        <p className="text-sm font-semibold text-slate-800">{title}</p>
        <p className="text-xs text-slate-500 mt-1 leading-relaxed">{description}</p>
      </div>
    </div>
  )
}

// ─── Progress chart ──────────────────────────────────────────────────────────

function ProgressChart({ sessions }: { sessions: SessionSummary[] }) {
  const values = sessions.map(s => s.metrics.wcpm)
  const maxVal = Math.max(...values, 1)
  const W = 320, H = 110
  const padL = 10, padR = 10, padT = 10, padB = 24
  const chartW = W - padL - padR
  const chartH = H - padT - padB

  const pts = values.map((v, i) => ({
    x: padL + (i / Math.max(values.length - 1, 1)) * chartW,
    y: padT + (1 - v / maxVal) * chartH,
  }))

  const dx = (chartW / Math.max(values.length - 1, 1)) / 3
  let smoothPath = `M ${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`
  for (let i = 1; i < pts.length; i++) {
    smoothPath += ` C ${(pts[i-1].x + dx).toFixed(1)},${pts[i-1].y.toFixed(1)} ${(pts[i].x - dx).toFixed(1)},${pts[i].y.toFixed(1)} ${pts[i].x.toFixed(1)},${pts[i].y.toFixed(1)}`
  }
  const areaPath = `${smoothPath} L ${pts[pts.length-1].x.toFixed(1)},${padT+chartH} L ${pts[0].x.toFixed(1)},${padT+chartH} Z`

  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="w-full" aria-label="WCPM over sessions">
      <defs>
        <linearGradient id="homeAreaGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#3b82f6" stopOpacity="0.18" />
          <stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
        </linearGradient>
      </defs>
      <path d={areaPath} fill="url(#homeAreaGrad)" />
      <path d={smoothPath} fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      {pts.map((p, i) => (
        <circle key={i} cx={p.x} cy={p.y} r="3.5" fill="#3b82f6" />
      ))}
      {pts.map((p, i) => (
        <text key={i} x={p.x} y={H - 4} fontSize="9" fill="#94a3b8" textAnchor="middle">
          {i + 1}
        </text>
      ))}
    </svg>
  )
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

function filterSessions(sessions: SessionSummary[], filter: TimeFilter): SessionSummary[] {
  const now = Date.now()
  if (filter === 'Last 3') return [...sessions].reverse().slice(0, 3).reverse()
  const ms: Record<TimeFilter, number> = {
    'This Hour':  60 * 60 * 1000,
    'This Week':  7 * 24 * 60 * 60 * 1000,
    'This Month': 30 * 24 * 60 * 60 * 1000,
    'Last 3': 0,
  }
  return sessions.filter(s => now - s.timestamp <= ms[filter])
}

function formatDate(ts: number) {
  return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}

function accuracyColor(pct: number) {
  if (pct >= 90) return 'bg-green-100 text-green-700'
  if (pct >= 80) return 'bg-amber-100 text-amber-700'
  return 'bg-red-100 text-red-700'
}

function passageLabel(s: SessionSummary) {
  return s.passageTitle ?? s.passageId.replace(/-g\d+$/, '').replace(/-/g, ' ')
}

// ─── Sessions + Progress section (client) ────────────────────────────────────

const TIME_FILTERS: TimeFilter[] = ['This Hour', 
[truncated — 11577 more characters]
```

### src/app/progress/page.tsx

```typescript
'use client'

import { useEffect, useState } from 'react'
import Link from 'next/link'
import LoadingDots from '@/components/LoadingDots'

function SessionDetail({ s }: { s: SessionSummary }) {
  const errors = s.errorCounts
  const totalErrors = errors.substitutions + errors.omissions + errors.insertions + errors.hesitations
  const maxErr = Math.max(errors.substitutions, errors.omissions, errors.insertions, errors.hesitations, 1)

  return (
    <div className="mt-2 mb-1 bg-slate-50 rounded-xl p-4 space-y-3">
      <div className="grid grid-cols-3 gap-3 text-center">
        <div>
          <p className="text-xs text-slate-400 mb-0.5">WCPM</p>
          <p className="text-lg font-bold text-slate-800">{s.metrics.wcpm}</p>
        </div>
        <div>
          <p className="text-xs text-slate-400 mb-0.5">Accuracy</p>
          <p className="text-lg font-bold text-slate-800">{Math.round(s.metrics.accuracy)}%</p>
        </div>
        <div>
          <p className="text-xs text-slate-400 mb-0.5">Duration</p>
          <p className="text-lg font-bold text-slate-800">{Math.round(s.metrics.durationSeconds)}s</p>
        </div>
      </div>

      <div>
        <p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Error Breakdown</p>
        <div className="space-y-1.5">
          {([
            ['Substitutions', errors.substitutions],
            ['Omissions', errors.omissions],
            ['Insertions', errors.insertions],
            ['Hesitations', errors.hesitations],
          ] as [string, number][]).map(([label, count]) => (
            <div key={label} className="flex items-center gap-2">
              <span className="w-24 text-xs text-slate-500 shrink-0">{label}</span>
              <div className="flex-1 bg-slate-200 rounded-full h-1.5">
                <div
                  className="h-1.5 rounded-full bg-red-400"
                  style={{ width: `${Math.round((count / maxErr) * 100)}%` }}
                />
              </div>
              <span className="w-3 text-xs text-slate-600 shrink-0">{count}</span>
            </div>
          ))}
        </div>
      </div>

      <div className="flex items-center justify-between text-xs text-slate-500 pt-1 border-t border-slate-200">
        <span>Grade {s.passageGrade} passage</span>
        <span>{totalErrors} total error{totalErrors !== 1 ? 's' : ''}</span>
        {s.selfCorrections > 0 && <span>{s.selfCorrections} self-correction{s.selfCorrections !== 1 ? 's' : ''}</span>}
      </div>
    </div>
  )
}

const READER_ID_KEY = 'fluently-reader-id'
const MIN_SESSIONS = 3

interface SessionSummary {
  sessionId: string
  timestamp: number
  passageId: string
  passageTitle?: string
  passageGrade: number
  metrics: {
    wcpm: number
    accuracy: number
    durationSeconds: number
  }
  errorCounts: {
    substitutions: number
    omissions: number
    insertions: number
    hesitations: number
  }
  selfCorrections: number
}

// Simple SVG line chart for WCPM over sessions
function SessionChart({ sessions }: { sessions: SessionSummary[] }) {
  const values = sessions.map(s => s.metrics.wcpm)
  const maxVal = Math.max(...values, 1)
  const W = 340
  const H = 100
  const padL = 10, padR = 10, padT = 10, padB = 20
  const chartW = W - padL - padR
  const chartH = H - padT - padB

  const pts = values.map((v, i) => ({
    x: padL + (i / Math.max(values.length - 1, 1)) * chartW,
    y: padT + (1 - v / maxVal) * chartH
  }))

  const dx = (chartW / Math.max(values.length - 1, 1)) / 3
  let smoothPath = `M ${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`
  for (let i = 1; i < pts.length; i++) {
    smoothPath += ` C ${(pts[i - 1].x + dx).toFixed(1)},${pts[i - 1].y.toFixed(1)} ${(pts[i].x - dx).toFixed(1)},${pts[i].y.toFixed(1)} ${pts[i].x.toFixed(1)},${pts[i].y.toFixed(1)}`
  }
  const areaPath = `${smoothPath} L ${pts[pts.length - 1].x.toFixed(1)},${padT + chartH} L ${pts[0].x.toFixed(1)},${padT + chartH} Z`

  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="w-full" aria-label="WCPM over sessions">
      <defs>
        <linearGradient id="progressAreaGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#3b82f6" stopOpacity="0.2" />
          <stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
        </linearGradient>
      </defs>
      <path d={areaPath} fill="url(#progressAreaGrad)" />
      <path d={smoothPath} fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      {pts.map((p, i) => (
        <circle key={i} cx={p.x} cy={p.y} r="3.5" fill="#3b82f6" />
      ))}
      {pts.map((p, i) => (
        <text key={i} x={p.x} y={H - 2} fontSize="9" fill="#94a3b8" textAnchor="middle">
          {i + 1}
        </text>
      ))}
    </svg>
  )
}

function formatDate(timestamp: number) {
  return new Date(timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}

function accuracyBadge(pct: number) {
  if (pct >= 95) return 'bg-green-100 text-green-700'
  if (pct >= 85) return 'bg-amber-100 text-amber-700'
  return 'bg-red-100 text-red-700'
}

export default function ProgressPage() {
  const [sessions, setSessions] = useState<SessionSummary[] | null>(null)
  const [loading, setLoading] = useState(true)
  const [expanded, setExpanded] = useState<string | null>(null)

  useEffect(() => {
    const readerId = localStorage.getItem(READER_ID_KEY)
    if (!readerId) {
      setSessions([])
      setLoading(false)
      return
    }
    fetch(`/api/session?readerId=${encodeURIComponent(readerId)}`)
      .then(r => r.json())
      .then(d => {
        setSessions(d.sessions ?? [])
      })
      .catch(() => setSessions([]))
      .finally(() => setLoading(false))
  }, [])

  if (loading) {
    return (
      <main className="min-h-screen bg-slate-50 flex items-center justify-center">
        <LoadingDots />
      </main>
    )
  }

  const hasEnoughSessions = sessions !== null && sessions.length >= MIN_SESSIONS

  // Emp
[truncated — 5977 more characters]
```

### src/app/api/deepgram-token/route.ts

```typescript
// Fluently — Deepgram Token Endpoint
// Returns the Deepgram API key server-side so it never reaches the browser directly
// In production, this would use Deepgram's temporary token API

export async function GET() {
  const apiKey = process.env.DEEPGRAM_API_KEY
  if (!apiKey) {
    return Response.json({ error: 'Deepgram API key not configured' }, { status: 500 })
  }
  // For hackathon: return key directly
  // For production: use Deepgram's /v1/auth/grant endpoint for temporary tokens
  return Response.json({ key: apiKey })
}

```

### src/app/api/generate-passage/route.ts

```typescript
import { generatePassage } from '@/lib/generatePassage'

export async function POST(request: Request) {
  try {
    const { complexity, register } = await request.json()

    if (typeof complexity !== 'number' || typeof register !== 'number') {
      return Response.json({ error: 'Missing complexity or register' }, { status: 400 })
    }

    const passage = await generatePassage(complexity, register)
    return Response.json(passage)
  } catch (error) {
    console.error('generate-passage error:', error)
    return Response.json({ error: 'Failed to generate passage' }, { status: 500 })
  }
}

```

### src/app/practice/page.tsx

```typescript
'use client'

import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { SessionState, WordTimestamp, AlignedWord, Metrics, Passage, WordStatus, DiagnosticResponse, Recommendation, HistoryPoint, NextPassageRecommendation } from '@/lib/types'
import { align } from '@/lib/alignment'
import AudioRecorder from '@/components/AudioRecorder'
import PassageDisplay from '@/components/PassageDisplay'
import DiagnosticReport, { ReadingHistory } from '@/components/DiagnosticReport'
import MetricsDashboard from '@/components/MetricsDashboard'
import PassageMap from '@/components/PassageMap'
import LoadingDots from '@/components/LoadingDots'

const SESSION_DURATION = 60

function formatTime(s: number): string {
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}

function getErrorType(metrics: Metrics): DiagnosticResponse['errorType'] {
  const decodingIssue = metrics.accuracy < 90
  const phrasingIssue = metrics.pausePlacement.boundaryPercent < 50
  if (decodingIssue && phrasingIssue) return 'mixed'
  if (decodingIssue) return 'decoding'
  if (phrasingIssue) return 'phrasing'
  return 'fluent'
}

function slugify(title: string): string {
  return title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
}

const READER_ID_KEY = 'fluently-reader-id'

export default function PracticePage() {
  const [sessionState, setSessionState] = useState<SessionState>('idle')
  const [passage, setPassage] = useState<Passage | null>(null)
  const [isGenerating, setIsGenerating] = useState(false)
  const [mapComplexity, setMapComplexity] = useState(0.5)
  const [mapRegister, setMapRegister] = useState(0.5)
  const [wordStream, setWordStream] = useState<WordTimestamp[]>([])
  const [aligned, setAligned] = useState<AlignedWord[]>([])
  const [metrics, setMetrics] = useState<Metrics | null>(null)
  const [report, setReport] = useState<string>('')
  const [recommendation, setRecommendation] = useState<Recommendation>('retry')
  const [reasoning, setReasoning] = useState<string>('')
  const [error, setError] = useState<string>('')
  const [timerSeconds, setTimerSeconds] = useState(0)
  const [nextPassage, setNextPassage] = useState<NextPassageRecommendation | null>(null)
  const [history, setHistory] = useState<HistoryPoint[]>([])
  const [processingStep, setProcessingStep] = useState<1 | 2 | 3>(1)

  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
  const passageRef = useRef<Passage | null>(null)
  const readerIdRef = useRef<string>('')
  const sessionIdRef = useRef<string>('')

  useEffect(() => { passageRef.current = passage }, [passage])

  useEffect(() => {
    let id = localStorage.getItem(READER_ID_KEY)
    if (!id) {
      id = crypto.randomUUID()
      localStorage.setItem(READER_ID_KEY, id)
    }
    readerIdRef.current = id
  }, [])

  const stopTimer = useCallback(() => {
    if (timerRef.current !== null) {
      clearInterval(timerRef.current)
      timerRef.current = null
    }
  }, [])

  useEffect(() => () => stopTimer(), [stopTimer])

  const handleGeneratePassage = useCallback(async (complexity: number, register: number) => {
    setMapComplexity(complexity)
    setMapRegister(register)
    setIsGenerating(true)
    setError('')
    try {
      const res = await fetch('/api/generate-passage', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ complexity, register })
      })
      const data = await res.json()
      if (data.error) throw new Error(data.error)
      setPassage(data)
      setSessionState('idle')
      setWordStream([])
      setAligned([])
      setMetrics(null)
      setReport('')
      setRecommendation('retry')
      setReasoning('')
      setNextPassage(null)
      setHistory([])
    } catch {
      setError('Failed to generate passage. Please try again.')
    } finally {
      setIsGenerating(false)
    }
  }, [])

  const handleWord = useCallback((word: WordTimestamp) => {
    setWordStream(prev => [...prev, word])
  }, [])

  useEffect(() => {
    if (sessionState !== 'recording' || !passageRef.current || wordStream.length === 0) return
    const gotWords = wordStream.map(w => w.word)
    const lookAhead = Math.min(gotWords.length + 3, passageRef.current!.words.length)
    const partialExpected = passageRef.current!.words.slice(0, lookAhead)
    setAligned(align(partialExpected, gotWords, wordStream))
  }, [wordStream, sessionState])

  const wordStatuses = useMemo(() => {
    const map = new Map<number, WordStatus>()
    aligned.forEach(w => {
      if (w.status !== 'insertion') map.set(w.index, w.status)
    })
    for (let i = 1; i < wordStream.length; i++) {
      const gap = (wordStream[i].start - (wordStream[i - 1].start + wordStream[i - 1].duration)) * 1000
      if (gap > 500) {
        const hit = aligned.find(w => w.timestamp?.start === wordStream[i].start)
        if (hit && map.get(hit.index) === 'correct') {
          map.set(hit.index, 'hesitation')
        }
      }
    }
    const CONFIDENCE_THRESHOLD = 0.8
    aligned.forEach(w => {
      const status = map.get(w.index)
      if ((status === 'substitution' || status === 'insertion') &&
          w.timestamp && w.timestamp.confidence < CONFIDENCE_THRESHOLD) {
        map.set(w.index, 'uncertain')
      }
    })
    return map
  }, [aligned, wordStream])

  const handleSessionEnd = useCallback(async () => {
    const currentPassage = passageRef.current
    if (!currentPassage) return
    stopTimer()
    if (wordStream.length === 0) {
      setSessionState('idle')
      setError('No speech detected. Please try again.')
      return
    }
    setSessionState('processing')
    setProcessingStep(1)
    try {
      const gotWords = wordStream.map(w => w.word)
      const alignedWords = align(currentPassage.words, gotWords, wordStream)
      setAligned(alignedWords)
      setProcessingStep(2)
      const { computeMetrics } = await import('@/lib/metrics')
      const computedMet
[truncated — 17013 more characters]
```

### src/app/api/session/route.ts

```typescript
import { getRedis } from '@/lib/redis'
import { Metrics } from '@/lib/types'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const readerId = searchParams.get('readerId')

  if (!readerId) {
    return Response.json({ error: 'Missing readerId' }, { status: 400 })
  }

  try {
    const redis = await getRedis()
    const indexKey = `reader:${readerId}:sessionIndex`
    const sessionIds = await redis.lRange(indexKey, 0, -1)

    if (sessionIds.length === 0) {
      return Response.json({ sessions: [], sessionCount: 0 })
    }

    const docs = await Promise.all(
      sessionIds.map((sid: string) => redis.get(`reader:${readerId}:sessions:${sid}`))
    )
    const sessions = docs.filter(Boolean).map((d: string | null) => JSON.parse(d as string))
    return Response.json({ sessions, sessionCount: sessions.length })
  } catch (error) {
    console.error('Session fetch error:', error)
    return Response.json({ error: 'Failed to fetch sessions' }, { status: 500 })
  }
}

const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days

interface SessionLogRequest {
  readerId: string
  sessionId: string
  passageId: string
  passageTitle: string
  passageGrade: number
  passageComplexity?: number
  passageRegister?: number
  metrics: Metrics
  skillVector: number[]
}

export async function POST(request: Request) {
  try {
    const body: SessionLogRequest = await request.json()
    const { readerId, sessionId, passageId, passageTitle, passageGrade, passageComplexity, passageRegister, metrics, skillVector } = body

    if (!readerId || !sessionId || !metrics || !skillVector) {
      return Response.json({ error: 'Missing required session fields' }, { status: 400 })
    }

    const redis = await getRedis()

    // Exact schema per docs/ROADMAP.md "Redis AI Integration" — errorDetail (per-error POS/
    // semantic-class tagging) is omitted for now; that data doesn't exist until the separate
    // "Semantic substitution classification" stretch feature is built.
    const sessionDoc = {
      sessionId,
      readerId,
      timestamp: Date.now(),
      passageId,
      passageTitle,
      passageGrade,
      passageComplexity,
      passageRegister,
      metrics: {
        wcpm: metrics.wcpm,
        accuracy: metrics.accuracy,
        durationSeconds: metrics.durationSeconds,
        correctWords: metrics.correctWords,
        totalWords: metrics.totalWords
      },
      errorCounts: metrics.errorCounts,
      pausePlacement: metrics.pausePlacement,
      selfCorrections: metrics.selfCorrections,
      selfCorrectionRate: metrics.selfCorrectionRate,
      skillVector
    }

    const sessionKey = `reader:${readerId}:sessions:${sessionId}`
    const indexKey = `reader:${readerId}:sessionIndex`

    await redis.set(sessionKey, JSON.stringify(sessionDoc), { EX: SESSION_TTL_SECONDS })
    const sessionCount = await redis.rPush(indexKey, sessionId)
    await redis.expire(indexKey, SESSION_TTL_SECONDS)

    return Response.json({ sessionCount })
  } catch (error) {
    console.error('Session log error:', error)
    return Response.json({ error: 'Failed to log session' }, { status: 500 })
  }
}

```

### src/app/api/diagnose/route.ts

```typescript
import Anthropic from '@anthropic-ai/sdk'
import { getRedis } from '@/lib/redis'
import { computeNextTarget, findNearestPassage, weakestDimensionLabel, type StoredPassage } from '@/lib/passageVectors'
import { generatePassage } from '@/lib/generatePassage'
import { Metrics, Recommendation } from '@/lib/types'

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })

interface SessionDoc {
  sessionId: string
  timestamp: number
  metrics: { wcpm: number; accuracy: number }
  errorCounts: { substitutions: number; omissions: number; insertions: number; hesitations: number }
  pausePlacement: { boundaryPercent: number }
  selfCorrectionRate: number
}

// Prior sessions only — the current session hasn't been written to Redis yet (page.tsx logs it
// separately, fire-and-forget, after this call). Degrades to an empty array (snapshot mode) on
// any Redis error instead of failing the whole report.
async function fetchPriorSessions(readerId: string): Promise<SessionDoc[]> {
  try {
    const redis = await getRedis()
    const sessionIds = await redis.lRange(`reader:${readerId}:sessionIndex`, 0, -1)
    if (sessionIds.length === 0) return []
    const docs = await Promise.all(
      sessionIds.map(id => redis.get(`reader:${readerId}:sessions:${id}`))
    )
    return docs
      .filter((d): d is string => d !== null)
      .map(d => JSON.parse(d) as SessionDoc)
      .sort((a, b) => a.timestamp - b.timestamp)
  } catch (error) {
    console.error('Failed to fetch session history, falling back to snapshot mode:', error)
    return []
  }
}

function longitudinalContext(prior: SessionDoc[], current: Metrics): string {
  if (prior.length === 0) return ''

  const row = (label: string, m: { wcpm: number; accuracy: number }, e: SessionDoc['errorCounts'], p: { boundaryPercent: number }, sc: number) =>
    `| ${label} | ${m.wcpm} | ${m.accuracy}% | ${e.substitutions} | ${e.omissions} | ${e.insertions} | ${e.hesitations} | ${p.boundaryPercent}% | ${Math.round(sc * 100)}% |`

  const rows = prior.map((s, i) =>
    row(`Session ${i + 1}`, s.metrics, s.errorCounts, s.pausePlacement, s.selfCorrectionRate)
  )
  rows.push(row(`Session ${prior.length + 1} (today)`, current, current.errorCounts, current.pausePlacement, current.selfCorrectionRate))

  const table = `| Session | WCPM | Accuracy | Subs | Omit | Ins | Hes | Boundary% | SelfCorr% |
|---|---|---|---|---|---|---|---|---|
${rows.join('\n')}`

  const verifyRule = 'Before writing the report, re-read every number you plan to cite directly off the table above — do not estimate or recall from memory.'

  if (prior.length === 1) {
    return `

READING HISTORY (comparison mode — this is the student's 2nd session):
${table}
Note which numbers improved, which regressed, and call out the most meaningful change explicitly. ${verifyRule}`
  }

  return `

READING HISTORY (pattern-recognition mode — this is the student's 3rd or later session, ${prior.length + 1} total):
${table}
Across these sessions, identify which error types are PERSISTING, WORSENING, or RESOLVING. Distinguish a true pattern (consistent across 3+ sessions) from session-to-session noise. If a specific error type or pause-placement trend stands out, name it explicitly and explain what it suggests (e.g. consistently low boundary-pause percent suggests a prosodic chunking issue, not a decoding issue). ${verifyRule}`
}

// DIBELS 8th Edition benchmarks — exact strategic/green/blue tiers for the original fixed grades
const BENCHMARKS: Record<number, { strategic: number; green: number; blue: number }> = {
  2: { strategic: 99,  green: 125, blue: 159 },
  4: { strategic: 125, green: 141, blue: 160 },
  6: { strategic: 121, green: 135, blue: 159 },
}
// DIBELS 8th Edition end-of-year "at benchmark" WCPM, grades 1-12 — fallback for AI-generated
// passages outside 2/4/6 (PassageMap spans the full grade 1-12 complexity range)
const DIBELS_EOY: Record<number, number> = {
  1: 71, 2: 107, 3: 124, 4: 133, 5: 142,
  6: 142, 7: 146, 8: 151, 9: 153, 10: 155, 11: 157, 12: 160
}
const ACCURACY_INDEPENDENT  = 95  // DIBELS/IEP independent reading level — advance threshold
const ACCURACY_INSTRUCTIONAL = 90  // DIBELS/IEP instructional level — retry threshold

interface DiagnoseRequest {
  metrics: Metrics
  passageGrade: number
  passageTitle: string
  passageId?: string    // present for AI-generated passages; excluded from next-passage matching
  targetWCPM?: number  // from generate-passage; preferred benchmark source when present
  readerId?: string     // present once page.tsx has generated/loaded a persistent reader identity
  complexity?: number  // 0-1, present for AI-generated passages
  register?: number    // 0-1, present for AI-generated passages
  skillVector?: number[] // from computeSkillVector; drives the next-passage recommendation
}

type Tier = 'intensive' | 'strategic' | 'core'
type Bench = { strategic: number; green: number; blue: number }

// Grades 2/4/6 have exact published tier cut points. For any other grade, derive
// approximate strategic/blue tiers from a single benchmark point (the passage's own
// targetWCPM when available, else the DIBELS EOY grade lookup) using the average
// ratio observed across the three known grades (~0.85 / ~1.2).
function resolveBench(grade: number, targetWCPM?: number): Bench {
  const exact = BENCHMARKS[grade]
  if (exact) return exact
  const green = targetWCPM ?? DIBELS_EOY[Math.min(Math.max(Math.round(grade), 1), 12)] ?? 160
  return { strategic: Math.round(green * 0.85), green, blue: Math.round(green * 1.2) }
}

function getTier(wcpm: number, accuracy: number, bench: Bench): Tier {
  // Frustration level: accuracy below instructional OR WCPM below 80% of benchmark
  if (accuracy < ACCURACY_INSTRUCTIONAL || wcpm < bench.green * 0.80) return 'intensive'
  // Instructional level: accuracy below independent OR WCPM below 90% of benchmark
  if (accuracy < ACCURACY_INDEPENDENT || wcpm < bench.green * 0.90) return 'strategic'
  // Independe
[truncated — 10136 more characters]
```

### postcss.config.js

```javascript
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

```

[21 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]