# Project export: SmartGlasses.study and GenerativeAI.study

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: OpenAI Build Week
- Tagline: GenerativeAI.study creates personalised learning content, while SmartGlasses.study turns it into hands-free, voice-controlled revision through Meta smart glasses.
- Devpost: https://devpost.com/software/smartglasses-study-and-generativeai-study
- GitHub: https://github.com/leeenglestone/OpenAIBuildWeek-Study/
- Video: https://www.youtube.com/embed/wrTT3MqpjiU?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Lee Englestone (17 commits)

## Devpost submission (written by the team)

### Inspiration

As a parent, I wanted to create a study tool that would help my children, aged 10 and 13, explore subjects beyond the classroom in a way that feels personal, accessible, and fun. Children do not always want to sit at a desk or open another textbook. However, they often have small pockets of time while walking, travelling on the bus, or waiting for an activity to begin. I wanted to explore whether AI and smart glasses could turn those moments into opportunities for convenient, hands-free learning. That idea became GenerativeAI.study and SmartGlasses.study: a connected learning platform that lets children create age-appropriate study material on the web and then practise it through interactive quizzes on a phone or using Meta smart glasses.

### What it does

GenerativeAI.study is a web application where learners can: Sign in and select a subject. Enter a topic they are currently studying or interested in. Generate age-appropriate facts and learning material. Create question-and-answer flashcards. Test their knowledge through multiple-choice quizzes. Save their learning content for future study sessions. The same learning material can then be accessed through the SmartGlasses.study Android application. The mobile app connects to Meta smart glasses and delivers an audio-based, hands-free learning experience. A learner hears a question and several plausible answers through the glasses, then responds using their voice. The application evaluates the answer, provides feedback, and continues the quiz. This means learners can revise without looking at a screen, for example, while walking, travelling on the bus, or during other times when traditional study materials would be inconvenient. The project combines AI-generated learning content with voice interaction and wearable technology to make studying more flexible, engaging, and integrated into everyday life. How I built it I built the complete solution as a solo project using Codex with GPT-5.6. I used Codex to take a description of the project, build out the requirements for it, and create a specification in different .md files before tasking it with building the solution based on the spec. The platform consists of several connected components: GenerativeAI.study, an ASP.NET MVC web application. A purpose-built API shared by the web and mobile applications. An Android application written in Kotlin using Android Studio. Meta Wearables Device Access Toolkit integration for communication with Meta smart glasses. Azure Table Storage for structured application data. Azure Blob Storage for additional stored content and application assets. OpenAI-powered generation of age-appropriate facts, flashcards, questions, and multiple-choice answers. The web application and Android application both communicate through the same API. This allows learning material created in GenerativeAI.study to become immediately available within SmartGlasses.study. Codex and GPT-5.6 were used throughout the project to: Design the application architecture. Build the ASP.NET MVC web application. Create the shared API. Implement Azure storage integration. Develop the Kotlin Android application. Work through unfamiliar SDK integration. Diagnose errors and connectivity issues. Refine the user experience and question-and-answer interaction loop. Generate and improve documentation. Rather than using Codex for isolated code snippets, I used it as a development partner across the complete product - from the initial architecture to the working web, API, cloud, mobile, and wearable components. Challenges I ran into One of the biggest challenges was learning how to build interactions for Meta smart glasses. I had limited previous experience with the Meta Wearables Device Access Toolkit and had not done much Kotlin or native Android development. Creating a reliable conversational quiz loop also required careful thought. The system needs to: Retrieve the learner’s saved material. Present a question through the glasses. Read several plausible answers aloud. Capture the learner’s spoken response. Determine which answer was selected. Provide useful feedback. Continue naturally to the next question. Bluetooth connectivity introduced additional considerations, including device state, connection handling, audio input and output, permissions, and communication between the glasses and the mobile application. The project was also completed by one person within a short hackathon timeframe. This required balancing web development, API design, cloud storage, Android development, AI integration, testing, and wearable-device experimentation. Codex was particularly valuable in helping me move between these unfamiliar technologies and make progress across the entire stack. What I learned This project gave me practical experience with: The Meta Wearables Device Access Toolkit. Kotlin and native Android application development. Bluetooth and wearable-device connectivity. Designing voice-first user experiences. Building conversational question-and-answer loops. Sharing data between web and mobile applications through a common API. Using Azure Table Storage and Blob Storage as part of a connected learning platform. Using Codex with GPT-5.6 to build across multiple languages, platforms, and technologies. I also learned that designing for smart glasses is not simply a matter of moving a mobile interface onto another device. A hands-free experience needs short instructions, clear answer options, useful audio feedback, and interactions that do not require the learner to look at a screen.

### What's next

My immediate goal is to continue improving the platform until my children can independently create learning material and use the glasses to enjoy practising subjects outside of school. Future development could include: Personalised difficulty based on previous answers. Spaced-repetition study sessions. Progress tracking for learners and parents. Support for different learning levels and curricula. Teacher-created topic collections. Improved voice recognition and conversational feedback. Explanations for incorrect answers. Achievement systems and learning streaks. More flexible revision sessions based on the learner’s available time. Additional wearable and audio-device support. Using more natural sounding TTS rather than the built in Android TTS. GenerativeAI.study creates the learning experience. SmartGlasses.study lets learners take it with them. Note: Both domain names GenerativeAI.study and SmartGlasses.study domain names have been registered. But ran out of time to put sites at those locations so have redirected them to the Git repository.

## README (from the GitHub repository)

# GenerativeAI.study

<p align="center">
  <img src="logos.png" alt="GenerativeAI.study and SmartGlasses.study logos" width="900">
</p>

An AI-assisted learning platform that organises age-appropriate lessons by subject. This implementation currently includes a .NET 8 REST API, an ASP.NET MVC web client, Azure Blob Storage with a local-development fallback, OpenAI Responses API integration, and automated API workflow tests.

## Solution Overview

![SmartGlasses.study solution overview showing the smart glasses, Android app, API, web application, OpenAI model, and Microsoft Azure services](overview.png)

The platform connects a voice-controlled smart-glasses learning experience with the SmartGlasses.study Android application, the GenerativeAI.study API and web application, OpenAI-powered content generation, and Microsoft Azure infrastructure.

## Projects

- `src/GenerativeAI.Contracts`: shared domain models and API request/response contracts
- `src/GenerativeAI.Api`: authentication, subjects, lessons, material generation, and progress endpoints
- `src/GenerativeAI.Web`: responsive MVC learning experience backed entirely by the API
- `src/GenerativeAI.Tests`: xUnit security and end-to-end API tests
- `src/mobile`: Kotlin/Jetpack Compose Android and smart-glasses companion client

When `AzureBlobStorage:ConnectionString` is configured, application data—including users, password hashes, refresh tokens, lessons, and progress—is persisted in Azure Blob Storage. Without it, local development falls back to `App_Data/study-data.json`.

## Prerequisites

- A .NET SDK capable of targeting .NET 8. The repository pins SDK `9.0.314`, which is installed in the current development environment.
- An OpenAI API key is required to generate facts. Browsing existing material works without one.

## Run Locally

From the repository root, restore and verify the solution:

```powershell
dotnet restore src/GenerativeAI.Study.sln
dotnet build src/GenerativeAI.Study.sln --no-restore
dotnet test src/GenerativeAI.Study.sln --no-restore
```

Start the API and web client in separate terminals:

```powershell
dotnet run --project src/GenerativeAI.Api
dotnet run --project src/GenerativeAI.Web
```

Open `http://localhost:5082`. The API health endpoint is `http://localhost:5081/health`.

Local data is created in `src/GenerativeAI.Api/App_Data/study-data.json` and is ignored by Git. The API seeds Maths, Science, History, and an Algebra basics lesson on first run.

## Configuration

Configuration is in each project's `appsettings.json` and can be overridden with user secrets or environment variables. Never commit production credentials.

```powershell
dotnet user-secrets init --project src/GenerativeAI.Api
dotnet user-secrets set "OpenAI:ApiKey" "YOUR_KEY" --project src/GenerativeAI.Api
dotnet user-secrets set "JwtSettings:SecretKey" "A_LONG_RANDOM_SECRET" --project src/GenerativeAI.Api
```

Relevant sections are `JwtSettings`, `OpenAI`, `Cors`, and `AzureBlobStorage`. Configure secrets outside `appsettings.json`:

```powershell
dotnet user-secrets set "OpenAI:ApiKey" "YOUR_OPENAI_KEY" --project src/GenerativeAI.Api
dotnet user-secrets set "AzureBlobStorage:ConnectionString" "YOUR_AZURE_STORAGE_CONNECTION_STRING" --project src/GenerativeAI.Api
```

The API creates the configured `study-data` container automatically. If the Azure connection string is absent, it uses local JSON storage; if the OpenAI key is absent, material generation returns `503 generation_unavailable`.

## API Surface

The API implements the authentication, subject, lesson, learning material, and progress routes from `3 - spec.md`. State-changing subject routes require an admin token; topic creation, generation, and progress routes require an authenticated learner. Access tokens are HMAC-SHA256 signed and passwords use PBKDF2-SHA256 with per-password salts.

## Tests

The test suite covers password hashing, signed-token validation/tampering, age groups, anonymous authorization, and the complete student workflow from registration through progress analytics. The first implementation milestone measures 78.4% line coverage for referenced application code.

## Web Application

The responsive GenerativeAI.study web application provides subject discovery, learning statistics, topic management, generated fact review, progress tracking, and administrative actions through the same secured API used by the mobile client.

| Subject dashboard | Topic and generated facts |
| --- | --- |
| <img src="desktop-1.png" alt="GenerativeAI.study desktop subject dashboard showing learning statistics and the History, Maths, and Science subject library" width="560"> | <img src="desktop-2.png" alt="GenerativeAI.study desktop Rome topic page showing difficulty controls and generated learning facts" width="560"> |

## Android App

The Android client uses Jetpack Compose, an OkHttp API client, Android Keystore AES-GCM token storage, automatic access-token refresh, manual dependency injection through `StudyApp`, and Meta's Wearables Device Access Toolkit (DAT). It includes registration/login, the complete flashcard workflow (topic creation, generation, review, study, questions, feedback, explanations, settings, and progress), topic/fact deletion, profile statistics, retryable offline states, and a connected smart-glasses quiz experience.

### Mobile application

| Topic dashboard | Multiple-choice quiz |
| --- | --- |
| <img src="mobile-1.jpg" alt="SmartGlasses.study Android topic dashboard showing learning topics and progress" width="320"> | <img src="mobile-2.jpg" alt="SmartGlasses.study Android multiple-choice quiz showing question four and four possible answers" width="320"> |

The Android app defaults to the deployed Azure API. To use a locally running API from the emulator, add `API_BASE_URL=http://10.0.2.2:5081/` to the ignored `src/mobile/local.properties`, then start the API before launching the app:

```powershell
dotnet run --project src/GenerativeAI.Api --urls http://0.0.0.0:5081
```

Open `src/mobile` in Android Studio, or build from PowerShell:

```powershell
$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
cd src/mobile
.\gradlew.bat testDebugUnitTest assembleDebug
```

The debug APK is generated at `src/mobile/app/build/outputs/apk/debug/app-debug.apk`.

The Meta DAT artifacts are hosted in GitHub Packages. Supply a GitHub personal access token with `read:packages` permission as either `GITHUB_TOKEN` or `github_token` in the ignored `src/mobile/local.properties` file:

```properties
github_token=YOUR_GITHUB_TOKEN
```

### Run on a physical Android device

1. Use an Android 10 or newer device with the Meta AI app installed and a supported pair of Meta glasses linked to it.
2. Enable Developer Mode for the glasses in the Meta AI app. For this development build, the DAT application ID and client token are both configured as `0`.
3. Enable Android Developer options and USB debugging, connect the phone by USB, and accept the debugging prompt.
4. Find the development machine's LAN IPv4 address (for example, with `ipconfig`). The phone and development machine must be on the same network.
5. Add the following machine-specific setting to the ignored `src/mobile/local.properties` file, using the development machine's address:

   ```properties
   API_BASE_URL=http://192.168.1.100:5081/
   ```

6. Start the API on all network interfaces with the command above. If Windows asks, allow port `5081` through the firewall for the private network.
7. Open `src/mobile` in Android Studio, let Gradle sync, select the connected device, and click **Run app**.
8. Open **Profile** in the app. Complete Meta AI registration if prompted, then select **Connect glasses**. The profile and quiz screens display the current connection state.

`API_BASE_URL` can also be supplied as a Gradle property (`-PAPI_BASE_URL=...`) or an environment variable. `local.properties` takes precedence over the environment 

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 51 recognized source files, 270 KB.
- C# (language) — detected in the code
- CSS (language) — detected in the code
- JavaScript (language) — detected in the code
- Kotlin (language) — detected in the code

## Codebase structure (from repository index)

### Files (87 of 87)

```
.gitignore
1 - project.md
2 - requirements.md
3 - spec.md
Directory.Build.props
global.json
plan.md
README.md
src/GenerativeAI.Api/appsettings.Development.json
src/GenerativeAI.Api/appsettings.json
src/GenerativeAI.Api/GenerativeAI.Api.csproj
src/GenerativeAI.Api/GenerativeAI.Api.http
src/GenerativeAI.Api/Program.cs
src/GenerativeAI.Api/Properties/launchSettings.json
src/GenerativeAI.Api/Services/MaterialGenerator.cs
src/GenerativeAI.Api/Services/StudyStore.cs
src/GenerativeAI.Api/Services/TokenService.cs
src/GenerativeAI.Contracts/GenerativeAI.Contracts.csproj
src/GenerativeAI.Contracts/Models.cs
src/GenerativeAI.Study.sln
src/GenerativeAI.Tests/ApiWorkflowTests.cs
src/GenerativeAI.Tests/GenerativeAI.Tests.csproj
src/GenerativeAI.Tests/SecurityTests.cs
src/GenerativeAI.Web/appsettings.Development.json
src/GenerativeAI.Web/appsettings.json
src/GenerativeAI.Web/Controllers/AccountController.cs
src/GenerativeAI.Web/Controllers/HomeController.cs
src/GenerativeAI.Web/Controllers/LessonsController.cs
src/GenerativeAI.Web/Controllers/ProgressController.cs
src/GenerativeAI.Web/Controllers/SubjectsController.cs
src/GenerativeAI.Web/GenerativeAI.Web.csproj
src/GenerativeAI.Web/Models/ErrorViewModel.cs
src/GenerativeAI.Web/Models/StudyViewModels.cs
src/GenerativeAI.Web/Program.cs
src/GenerativeAI.Web/Properties/launchSettings.json
src/GenerativeAI.Web/Services/StudyApiClient.cs
src/GenerativeAI.Web/Views/_ViewImports.cshtml
src/GenerativeAI.Web/Views/_ViewStart.cshtml
src/GenerativeAI.Web/Views/Account/Login.cshtml
src/GenerativeAI.Web/Views/Account/Register.cshtml
src/GenerativeAI.Web/Views/Home/Index.cshtml
src/GenerativeAI.Web/Views/Home/Privacy.cshtml
src/GenerativeAI.Web/Views/Lessons/Details.cshtml
src/GenerativeAI.Web/Views/Progress/Index.cshtml
src/GenerativeAI.Web/Views/Shared/_Layout.cshtml
src/GenerativeAI.Web/Views/Shared/_Layout.cshtml.css
src/GenerativeAI.Web/Views/Shared/_ValidationScriptsPartial.cshtml
src/GenerativeAI.Web/Views/Shared/Error.cshtml
src/GenerativeAI.Web/Views/Subjects/CreateLesson.cshtml
src/GenerativeAI.Web/Views/Subjects/Details.cshtml
src/GenerativeAI.Web/wwwroot/css/actions.css
src/GenerativeAI.Web/wwwroot/css/site.css
src/GenerativeAI.Web/wwwroot/js/site.js
src/GenerativeAI.Web/wwwroot/lib/bootstrap/LICENSE
src/GenerativeAI.Web/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js
src/GenerativeAI.Web/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js
src/GenerativeAI.Web/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt
src/GenerativeAI.Web/wwwroot/lib/jquery-validation/LICENSE.md
src/GenerativeAI.Web/wwwroot/lib/jquery/LICENSE.txt
src/mobile/.run/app.run.xml
src/mobile/app/build.gradle.kts
src/mobile/app/proguard-rules.pro
src/mobile/app/src/main/AndroidManifest.xml
src/mobile/app/src/main/java/com/generativeai/study/data/api/HttpStudyApi.kt
src/mobile/app/src/main/java/com/generativeai/study/data/api/SecureTokenStore.kt
src/mobile/app/src/main/java/com/generativeai/study/data/api/StudyApi.kt
src/mobile/app/src/main/java/com/generativeai/study/data/models/Models.kt
src/mobile/app/src/main/java/com/generativeai/study/data/repository/Repositories.kt
src/mobile/app/src/main/java/com/generativeai/study/di/AppContainer.kt
src/mobile/app/src/main/java/com/generativeai/study/glasses/GlassesAudioRoute.kt
src/mobile/app/src/main/java/com/generativeai/study/glasses/GlassesSession.kt
src/mobile/app/src/main/java/com/generativeai/study/glasses/VoiceQuizController.kt
src/mobile/app/src/main/java/com/generativeai/study/glasses/VoiceQuizForegroundService.kt
src/mobile/app/src/main/java/com/generativeai/study/MainActivity.kt
src/mobile/app/src/main/java/com/generativeai/study/StudyApp.kt
src/mobile/app/src/main/java/com/generativeai/study/ui/StudyScreens.kt
src/mobile/app/src/main/java/com/generativeai/study/ui/StudyViewModel.kt
src/mobile/app/src/main/res/values/styles.xml
src/mobile/app/src/test/java/com/generativeai/study/data/models/ContractModelTest.kt
src/mobile/app/src/test/java/com/generativeai/study/glasses/CompanionGlassesSessionTest.kt
src/mobile/app/src/test/java/com/generativeai/study/glasses/VoiceAnswerMatcherTest.kt
src/mobile/build.gradle.kts
src/mobile/gradle.properties
src/mobile/gradle/wrapper/gradle-wrapper.properties
src/mobile/gradlew
src/mobile/gradlew.bat
src/mobile/settings.gradle.kts
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- Readme.md updated
- Forgot to add desktop images
- Readme.md updated
- Quiz progress maintained and continued from stopping point. Glasses logo updated.
- Questions continuing to be read after screen goes off.
- Working well until the screen goes off
- Latest - trying to get glasses microphone anssering working
- Mobile orientation fixed. Topic deletion updated. API endpoints updated. Profile page added.
- Local persistKeys not local
- Mobile app updated
- Api updated
- Initial presentation created by AI - terrible. Will need to re-create
- Mobile app screens updated
- Mobile app added
- Delete functionality added. Errors when API key is missing.
- Initial commit
- Initial commit

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

### 1 - project.md

```markdown
# GenerativeAI.study and SmartGlasses.study

A solution for using AI to generate lesson learning material for school subjects and consume the content on the GenerativeAI.study and using Smart Glasses.

Organised by subject i.e. Maths, Science, History etc.

Users can create "Topics", and select an age level to generate facts that could be learned for that topic.

There should be a dedicated API that the web application and mobile app (that the smart glasses use) will consume.

The mobile app should be a Android Kotlin app that should also consume the API.

There should be an authentication layer.
```

### 2 - requirements.md

```markdown
# Project Requirements

## Overview

An AI-powered learning platform that generates educational content for school subjects, accessible via web and smart glasses through an Android app.

## Core Features

### Subject Management
- Organize content by school subjects (Maths, Science, History, etc.)
- Support for creating and managing subject categories

### Topic Creation
- Users can create custom "Topics" within subjects
- Topics serve as containers for learning material

### Age-Appropriate Content Generation
- Select age level for content generation
- AI generates facts and learning material appropriate for selected age
- Content adapts based on educational level

### Content Consumption
- Web application for browsing and consuming generated content
- Mobile app for smart glasses integration
- Both platforms consume the same API

## Technical Requirements

### API
- Dedicated API serving both web and mobile applications
- RESTful endpoints for:
  - Subject management
  - Topic CRUD operations
  - Content generation
  - User authentication

### Web Application
- Frontend interface for content browsing and topic creation
- Responsive design for various screen sizes

### Mobile Application
- Android app built with Kotlin
- Smart glasses integration
- API consumption for content display

### Authentication
- User authentication system
- Secure access to user-specific content
- Session management

## User Roles

- **Students**: Create topics, view generated content
- **Administrators**: Manage subjects, oversee content quality

## Success Criteria

- [ ] Users can sign up and authenticate
- [ ] Users can create topics within subjects
- [ ] Age-appropriate content generates successfully
- [ ] Content displays on web and mobile platforms
- [ ] Smart glasses integration functional

```

### src/mobile/build.gradle.kts

```kotlin
plugins {
    id("com.android.application") version "8.11.1" apply false
    id("org.jetbrains.kotlin.android") version "2.2.21" apply false
    id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
}

```

### src/mobile/settings.gradle.kts

```kotlin
import java.util.Properties

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("https://maven.pkg.github.com/facebook/meta-wearables-dat-android")
            credentials {
                username = ""
                val properties = Properties().apply {
                    val file = file("local.properties")
                    if (file.isFile) file.inputStream().use(::load)
                }
                password = System.getenv("GITHUB_TOKEN") ?: properties.getProperty("github_token")
            }
        }
    }
}

rootProject.name = "GenerativeAIStudy"
include(":app")

```

### src/GenerativeAI.Web/Program.cs

```c#
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using GenerativeAI.Web.Services;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(options => options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
builder.Services.AddDataProtection()
    .SetApplicationName("GenerativeAI.Study");
builder.Services.AddHttpContextAccessor();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
    options.LoginPath = "/account/login";
    options.AccessDeniedPath = "/account/login";
    options.Cookie.Name = "generativeai.study";
    options.Cookie.HttpOnly = true;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
});
builder.Services.AddHttpClient<StudyApiClient>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5081/");
    client.Timeout = TimeSpan.FromSeconds(35);
});

var app = builder.Build();
if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Home/Error");
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
    context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Append("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:");
    await next();
});
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.Run();

```

### src/GenerativeAI.Tests/SecurityTests.cs

```c#
using GenerativeAI.Api.Services;
using GenerativeAI.Contracts;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;

namespace GenerativeAI.Tests;

public sealed class SecurityTests
{
    [Fact]
    public void Password_hash_round_trips_without_storing_plaintext()
    {
        const string password = "A-useful-passphrase!";
        var hash = Passwords.Hash(password);

        Assert.DoesNotContain(password, hash);
        Assert.True(Passwords.Verify(password, hash));
        Assert.False(Passwords.Verify("wrong-password", hash));
    }

    [Fact]
    public void Access_token_contains_valid_identity_and_rejects_tampering()
    {
        var settings = new Dictionary<string, string?>
        {
            ["JwtSettings:SecretKey"] = "a-test-secret-that-is-at-least-32-bytes",
            ["JwtSettings:Issuer"] = "test-issuer",
            ["JwtSettings:Audience"] = "test-audience"
        };
        var service = new TokenService(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
        var user = new StoredUser("user-1", "student@example.com", "hash", Roles.Student, "12-14", DateTimeOffset.UtcNow);

        var token = service.CreateAccessToken(user).Token;
        var principal = service.Validate(token);

        Assert.Equal("student@example.com", principal?.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value);
        Assert.Null(service.Validate(token[..^1] + (token[^1] == 'a' ? 'b' : 'a')));
    }

    [Theory]
    [InlineData("6-8", true)]
    [InlineData("18+", true)]
    [InlineData("5-7", false)]
    [InlineData("", false)]
    public void Age_groups_are_closed_set(string value, bool expected) => Assert.Equal(expected, AgeGroups.IsValid(value));

    [Fact]
    public async Task Material_generation_reports_missing_api_key()
    {
        var generator = new MaterialGenerator(new HttpClient(), new ConfigurationBuilder().Build(), NullLogger<MaterialGenerator>.Instance);
        var subject = new Subject(Guid.NewGuid(), "Science", "Science", "flask", true);
        var lesson = new Lesson(Guid.NewGuid(), subject.Id, "Gravity", "Gravity", "12-14", [], DateTimeOffset.UtcNow, "test");

        var exception = await Assert.ThrowsAsync<MaterialGenerationException>(() => generator.GenerateAsync(subject, lesson, 3, 1, CancellationToken.None));

        Assert.Contains("API key has not been configured", exception.Message);
    }
}

```

### src/GenerativeAI.Contracts/Models.cs

```c#
using System.ComponentModel.DataAnnotations;

namespace GenerativeAI.Contracts;

public static class AgeGroups
{
    public static readonly string[] All = ["6-8", "9-11", "12-14", "15-17", "18+"];
    public static bool IsValid(string value) => All.Contains(value, StringComparer.Ordinal);
}

public static class Roles
{
    public const string Student = "student";
    public const string Admin = "admin";
}

public sealed record Subject(
    Guid Id,
    string Name,
    string Description,
    string Icon,
    bool IsActive = true);

public sealed record LearningMaterial(
    Guid Id,
    string Content,
    string Type,
    int Difficulty,
    string? Question = null,
    string? CorrectAnswer = null,
    IReadOnlyList<string>? IncorrectAnswers = null);

public sealed record Lesson(
    Guid Id,
    Guid SubjectId,
    string Name,
    string Description,
    string AgeGroup,
    IReadOnlyList<LearningMaterial> Materials,
    DateTimeOffset CreatedAt,
    string CreatedBy);

public sealed record LearningProgress(
    Guid Id,
    string UserId,
    Guid LessonId,
    Guid MaterialId,
    bool Completed,
    int Score,
    int TimeSpent,
    DateTimeOffset ViewedAt);

public sealed record RegisterRequest(
    [property: Required, EmailAddress, StringLength(254)] string Email,
    [property: Required, MinLength(8), MaxLength(128)] string Password,
    [property: Required] string AgeGroup);

public sealed record LoginRequest(
    [property: Required, EmailAddress] string Email,
    [property: Required] string Password);

public sealed record RefreshRequest([property: Required] string RefreshToken);

public sealed record AuthResponse(
    string AccessToken,
    string RefreshToken,
    DateTimeOffset ExpiresAt,
    string UserId,
    string Email,
    string Role,
    string AgeGroup);

public sealed record SubjectRequest(
    [property: Required, StringLength(80, MinimumLength = 2)] string Name,
    [property: Required, StringLength(300)] string Description,
    [property: StringLength(100)] string Icon,
    bool IsActive = true);

public sealed record LessonRequest(
    [property: Required, StringLength(120, MinimumLength = 2)] string Name,
    [property: Required, StringLength(500)] string Description,
    [property: Required] string AgeGroup);

public sealed record GenerateMaterialsRequest(
    [property: Range(1, 10)] int Count = 5,
    [property: Range(1, 5)] int Difficulty = 1);

public sealed record RecordProgressRequest(
    Guid LessonId,
    Guid MaterialId,
    bool Completed,
    [property: Range(0, 100)] int Score,
    [property: Range(0, 86400)] int TimeSpent);

public sealed record ProgressStats(
    int MaterialsViewed,
    int MaterialsCompleted,
    double AverageScore,
    int TotalTimeSpent,
    IReadOnlyList<LearningProgress> RecentActivity);

public sealed record ApiError(string Code, string Message, IDictionary<string, string[]>? Errors = null);

```

### src/GenerativeAI.Tests/ApiWorkflowTests.cs

```c#
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using GenerativeAI.Contracts;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;

namespace GenerativeAI.Tests;

public sealed class ApiWorkflowTests : IClassFixture<StudyApiFactory>
{
    private readonly HttpClient _client;
    public ApiWorkflowTests(StudyApiFactory factory) => _client = factory.CreateClient();

    [Fact]
    public async Task Student_can_register_create_topic_generate_material_and_record_progress()
    {
        var email = $"learner-{Guid.NewGuid():N}@example.com";
        var register = await _client.PostAsJsonAsync("/api/auth/register", new RegisterRequest(email, "StrongPassword!1", "12-14"));
        register.EnsureSuccessStatusCode();
        var auth = await register.Content.ReadFromJsonAsync<AuthResponse>();
        Assert.NotNull(auth);
        _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken);

        var subjects = await _client.GetFromJsonAsync<List<Subject>>("/api/subjects");
        var subject = Assert.Single(subjects!, item => item.Name == "Science");
        var created = await _client.PostAsJsonAsync($"/api/subjects/{subject.Id}/lessons", new LessonRequest("Ocean tides", "Why the sea rises and falls", "12-14"));
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        var lesson = await created.Content.ReadFromJsonAsync<Lesson>();
        Assert.NotNull(lesson);

        var generated = await _client.PostAsJsonAsync($"/api/lessons/{lesson.Id}/materials/generate", new GenerateMaterialsRequest(3, 2));
        generated.EnsureSuccessStatusCode();
        var materials = await generated.Content.ReadFromJsonAsync<List<LearningMaterial>>();
        Assert.Equal(3, materials!.Count);
        Assert.All(materials, material =>
        {
            Assert.False(string.IsNullOrWhiteSpace(material.Question));
            Assert.False(string.IsNullOrWhiteSpace(material.CorrectAnswer));
            Assert.Equal(3, material.IncorrectAnswers?.Count);
            Assert.DoesNotContain(material.CorrectAnswer!, material.IncorrectAnswers!);
        });

        var recorded = await _client.PostAsJsonAsync("/api/progress", new RecordProgressRequest(lesson.Id, materials[0].Id, true, 90, 75));
        recorded.EnsureSuccessStatusCode();
        var stats = await _client.GetFromJsonAsync<ProgressStats>("/api/progress/stats");
        Assert.Equal(1, stats!.MaterialsCompleted);
        Assert.Equal(90, stats.AverageScore);

        var deletedFact = await _client.DeleteAsync($"/api/lessons/{lesson.Id}/materials/{materials[0].Id}");
        Assert.Equal(HttpStatusCode.NoContent, deletedFact.StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/materials/{materials[0].Id}")).StatusCode);
        stats = await _client.GetFromJsonAsync<ProgressStats>("/api/progress/stats");
        Assert.Equal(0, stats!.MaterialsViewed);

        var deletedTopic = await _client.DeleteAsync($"/api/lessons/{lesson.Id}");
        Assert.Equal(HttpStatusCode.NoContent, deletedTopic.StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/lessons/{lesson.Id}")).StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/materials/{materials[1].Id}")).StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/materials/{materials[2].Id}")).StatusCode);
    }

    [Fact]
    public async Task Protected_progress_endpoint_rejects_anonymous_requests()
    {
        var response = await _client.GetAsync("/api/progress");
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, (await _client.DeleteAsync("/api/lessons/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")).StatusCode);
        Assert.Equal(HttpStatusCode.Unauthorized, (await _client.DeleteAsync("/api/lessons/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/materials/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")).StatusCode);
    }
}

public sealed class StudyApiFactory : WebApplicationFactory<Program>
{
    private readonly string _contentRoot = Path.Combine(Path.GetTempPath(), "generativeai-study-tests", Guid.NewGuid().ToString("N"));
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        Directory.CreateDirectory(_contentRoot);
        builder.UseContentRoot(_contentRoot).UseEnvironment("Testing").UseSetting("OpenAI:UseTestFallback", "true");
    }
}

```

### src/GenerativeAI.Api/Program.cs

```c#
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Threading.RateLimiting;
using GenerativeAI.Api.Services;
using GenerativeAI.Contracts;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StudyStore>();
builder.Services.AddSingleton<TokenService>();
builder.Services.AddHttpClient<IMaterialGenerator, MaterialGenerator>(client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddProblemDetails();
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
    .WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? ["http://localhost:5082"])
    .AllowAnyHeader().AllowAnyMethod()));
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
        RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ =>
            new FixedWindowRateLimiterOptions { PermitLimit = 120, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});

var app = builder.Build();
app.UseExceptionHandler();
app.UseCors();
app.UseRateLimiter();
app.UseMiddleware<BearerTokenMiddleware>();
app.Use(async (context, next) =>
{
    context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Append("Referrer-Policy", "no-referrer");
    await next();
});

app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));

var auth = app.MapGroup("/api/auth");
auth.MapPost("/register", async (RegisterRequest request, StudyStore store, TokenService tokens, CancellationToken ct) =>
{
    var validation = Validate(request);
    if (validation is not null) return validation;
    if (!AgeGroups.IsValid(request.AgeGroup)) return BadRequest("age_group", "Choose a supported age group.");
    var normalized = request.Email.Trim().ToLowerInvariant();
    var created = await store.WriteAsync(data =>
    {
        if (data.Users.Any(user => user.Email == normalized)) return null;
        var user = new StoredUser(Guid.NewGuid().ToString("N"), normalized, Passwords.Hash(request.Password), Roles.Student, request.AgeGroup, DateTimeOffset.UtcNow);
        data.Users.Add(user);
        return user;
    }, ct);
    return created is null ? Results.Conflict(new ApiError("email_exists", "An account with that email already exists.")) : await IssueTokens(created, store, tokens, ct);
});
auth.MapPost("/login", async (LoginRequest request, StudyStore store, TokenService tokens, CancellationToken ct) =>
{
    var normalized = request.Email.Trim().ToLowerInvariant();
    var user = await store.ReadAsync(data => data.Users.SingleOrDefault(item => item.Email == normalized), ct);
    return user is null || !Passwords.Verify(request.Password, user.PasswordHash)
        ? Results.Json(new ApiError("invalid_credentials", "Email or password is incorrect."), statusCode: 401)
        : await IssueTokens(user, store, tokens, ct);
});
auth.MapPost("/refresh", async (RefreshRequest request, StudyStore store, TokenService tokens, CancellationToken ct) =>
{
    var hash = Passwords.Sha256(request.RefreshToken);
    var user = await store.WriteAsync(data =>
    {
        var stored = data.RefreshTokens.SingleOrDefault(item => item.Hash == hash && item.ExpiresAt > DateTimeOffset.UtcNow);
        if (stored is null) return null;
        data.RefreshTokens.Remove(stored);
        return data.Users.SingleOrDefault(item => item.Id == stored.UserId);
    }, ct);
    return user is null ? Results.Json(new ApiError("invalid_refresh_token", "Refresh token is invalid or expired."), statusCode: 401) : await IssueTokens(user, store, tokens, ct);
});

var subjects = app.MapGroup("/api/subjects");
subjects.MapGet("/", async (bool? includeInactive, StudyStore store, CancellationToken ct) =>
    Results.Ok(await store.ReadAsync(data => data.Subjects.Where(item => includeInactive == true || item.IsActive).OrderBy(item => item.Name).ToList(), ct)));
subjects.MapPost("/", async (SubjectRequest request, HttpContext context, StudyStore store, CancellationToken ct) =>
{
    if (!IsAdmin(context)) return Forbidden(context);
    var validation = Validate(request); if (validation is not null) return validation;
    var subject = new Subject(Guid.NewGuid(), request.Name.Trim(), request.Description.Trim(), request.Icon.Trim(), request.IsActive);
    await store.WriteAsync(data => { data.Subjects.Add(subject); return subject; }, ct);
    return Results.Created($"/api/subjects/{subject.Id}", subject);
});
subjects.MapPut("/{id:guid}", async (Guid id, SubjectRequest request, HttpContext context, StudyStore store, CancellationToken ct) =>
{
    if (!IsAdmin(context)) return Forbidden(context);
    var updated = await store.WriteAsync(data =>
    {
        var index = data.Subjects.FindIndex(item => item.Id == id); if (index < 0) return null;
        return data.Subjects[index] = new Subject(id, request.Name.Trim(), request.Description.Trim(), request.Icon.Trim(), request.IsActive);
    }, ct);
    return updated is null ? Results.NotFound() : Results.Ok(updated);
});
subjects.MapDelete("/{id:guid}", async (Guid id, HttpContext context, StudyStore store, CancellationToken ct) =>
{
    if (!IsAdmin(context)) return Forbidden(context);
    var removed = await store.WriteAsync(data => data.Subjects.RemoveAll(item => item.Id == id) > 0, ct);
    return removed ? Results.NoContent() : Results.NotFound();
});
subjects.MapGet("/{id:guid}/lessons", async (Guid id, string? ageGroup, StudyStore store, CancellationToken ct) =>
    Results.Ok(await store.ReadAsync(data => data.Lessons.Where(item => item.SubjectId == id && (ageGroup == null || item.AgeGroup == ageGroup)).OrderByDescending(item => item.CreatedAt).ToList(), ct)));
subjects.MapPost("/{id:guid}/lessons", async (Guid id, LessonRequest request, HttpContext context, StudyStore store, Ca
[truncated — 8640 more characters]
```

### src/GenerativeAI.Web/Models/ErrorViewModel.cs

```c#
namespace GenerativeAI.Web.Models;

public class ErrorViewModel
{
    public string? RequestId { get; set; }

    public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
}

```

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