diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..bcb58e41cdd0bc2e44d80a132865e7f076dfc81c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,187 @@
+# =============================================================================
+# .dockerignore - Exclude files from Docker build context
+# =============================================================================
+# This file ensures clean, efficient Docker builds by excluding unnecessary
+# files from the build context. Smaller context = faster builds.
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Python Artifacts
+# -----------------------------------------------------------------------------
+# Bytecode, compiled files, and Python build artifacts
+__pycache__/
+**/__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+
+# -----------------------------------------------------------------------------
+# Virtual Environments
+# -----------------------------------------------------------------------------
+# Local Python virtual environments - Docker creates its own
+therm_venv/
+venv/
+.venv/
+env/
+ENV/
+.env.local
+
+# -----------------------------------------------------------------------------
+# IDE and Editor Files
+# -----------------------------------------------------------------------------
+# Editor-specific settings and temporary files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.project
+.pydevproject
+.settings/
+*.sublime-project
+*.sublime-workspace
+
+# -----------------------------------------------------------------------------
+# Version Control
+# -----------------------------------------------------------------------------
+# Git history and configuration (not needed in container)
+.git/
+.gitignore
+.gitattributes
+
+# -----------------------------------------------------------------------------
+# Testing and Coverage
+# -----------------------------------------------------------------------------
+# Test files, caches and coverage reports
+tests/
+.pytest_cache/
+htmlcov/
+.coverage
+.coverage.*
+coverage.xml
+*.cover
+.hypothesis/
+.tox/
+.nox/
+
+# -----------------------------------------------------------------------------
+# Development Tools
+# -----------------------------------------------------------------------------
+# Linting, type checking, and pre-commit caches
+.pre-commit-config.yaml
+.mypy_cache/
+.ruff_cache/
+.dmypy.json
+dmypy.json
+
+# -----------------------------------------------------------------------------
+# Data and Build Pipeline
+# -----------------------------------------------------------------------------
+# Raw data and build scripts (artifacts downloaded at runtime)
+data/
+scripts/
+
+# -----------------------------------------------------------------------------
+# Frontend
+# -----------------------------------------------------------------------------
+# Next.js frontend has its own Dockerfile
+frontend/
+
+# -----------------------------------------------------------------------------
+# Documentation and Project Files
+# -----------------------------------------------------------------------------
+# Markdown docs, documentation directories, and project-specific files
+*.md
+docs/
+CLAUDE.md
+PROJECT_README.md
+RAG_Chatbot_Plan.md
+IMPLEMENTATION_PLAN.md
+README.rst
+LICENSE
+
+# -----------------------------------------------------------------------------
+# Development Files
+# -----------------------------------------------------------------------------
+# Development-only files not needed in production
+commands
+raw_data/
+
+# -----------------------------------------------------------------------------
+# Environment and Secrets
+# -----------------------------------------------------------------------------
+# Environment files containing secrets (injected at runtime)
+.env
+.env.*
+*.local
+.secrets
+
+# -----------------------------------------------------------------------------
+# Build Artifacts
+# -----------------------------------------------------------------------------
+# Python package build outputs
+dist/
+build/
+*.egg-info/
+*.egg
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+MANIFEST
+
+# -----------------------------------------------------------------------------
+# Jupyter Notebooks
+# -----------------------------------------------------------------------------
+# Notebooks and checkpoints (development only)
+*.ipynb
+.ipynb_checkpoints/
+
+# -----------------------------------------------------------------------------
+# Logs
+# -----------------------------------------------------------------------------
+# Log files generated during development
+*.log
+logs/
+log/
+
+# -----------------------------------------------------------------------------
+# Docker Files
+# -----------------------------------------------------------------------------
+# Prevent recursive Docker context issues
+Dockerfile*
+docker-compose*
+.docker/
+.dockerignore
+
+# -----------------------------------------------------------------------------
+# Miscellaneous
+# -----------------------------------------------------------------------------
+# OS-generated files and other artifacts
+.DS_Store
+Thumbs.db
+*.bak
+*.tmp
+*.temp
+.cache/
+tmp/
+temp/
+
+# -----------------------------------------------------------------------------
+# CI/CD Configuration
+# -----------------------------------------------------------------------------
+# CI configuration files not needed in container
+.github/
+.gitlab-ci.yml
+.travis.yml
+.circleci/
+Makefile
+Taskfile.yml
+
+# =============================================================================
+# IMPORTANT: Files that ARE included (not ignored):
+# - src/ (application source code)
+# - pyproject.toml (Poetry dependency specification)
+# - poetry.lock (reproducible dependency versions)
+# - py.typed (type hint marker file)
+# =============================================================================
diff --git a/.gitignore b/.gitignore
index 694a96a20b9082b1bfaf911a3490662b5d47ad51..d799270fb0a7140eea762c090567423b1a3b7d29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -152,8 +152,16 @@ cython_debug/
# User requested exclusions
IMPLEMENTATION_PLAN.md
RAG_Chatbot_Plan.md
+PROJECT_README.md
+CLAUDE.md
+commands
data/
+raw_data/
+tests/
+
+# Hidden directories (except specific ones)
.*/
!.gitignore
!.pre-commit-config.yaml
!.env.example
+!.dockerignore
diff --git a/CLAUDE.md b/CLAUDE.md
index 13574b03410bee21783ad7faa43a8d07066223e0..b0e35a715e461e5b6c2982e3de580177f0c4a411 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -55,7 +55,7 @@ Downloads prebuilt artifacts from HF dataset, serves FastAPI backend with hybrid
- `src/rag_chatbot/chunking/` - Structure-aware chunking with heading inheritance
- `src/rag_chatbot/embeddings/` - BGE encoder with float16 storage
- `src/rag_chatbot/retrieval/` - Hybrid retriever (dense + BM25 with RRF fusion)
-- `src/rag_chatbot/llm/` - Provider registry with fallback: Gemini -> Groq -> DeepSeek
+- `src/rag_chatbot/llm/` - Provider registry with fallback: Gemini -> DeepSeek -> Anthropic
- `src/rag_chatbot/api/` - FastAPI endpoints with SSE streaming
- `src/rag_chatbot/qlog/` - Async query logging to HF dataset
@@ -77,7 +77,7 @@ Downloads prebuilt artifacts from HF dataset, serves FastAPI backend with hybrid
## Environment Variables
Required secrets for deployment:
-- `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY` - LLM providers
+- `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY` - LLM providers
- `HF_TOKEN` - HuggingFace authentication
Key configuration:
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..0d96cd00c2135df2bd974d75aed1f77d94022842
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,654 @@
+# =============================================================================
+# RAG Chatbot Combined Dockerfile - HuggingFace Spaces Production Deployment
+# =============================================================================
+# This is a multi-stage Dockerfile that combines the Next.js frontend and
+# FastAPI backend into a single container, using nginx as a reverse proxy.
+#
+# Architecture Overview:
+# - External port: 7860 (HuggingFace Spaces requirement)
+# - nginx: Reverse proxy on port 7860
+# - Frontend: Next.js standalone on internal port 3000
+# - Backend: FastAPI/uvicorn on internal port 8000
+#
+# Routing:
+# - /api/* -> Backend (FastAPI) on port 8000
+# - /* -> Frontend (Next.js) on port 3000
+#
+# Stages (4 total):
+# 1. frontend-deps: Install npm dependencies
+# 2. frontend-builder: Build Next.js standalone output
+# 3. backend-builder: Export Python dependencies via Poetry
+# 4. runtime: Final image with nginx + Node.js + Python
+#
+# Target image size: < 1.5GB (no torch, no build dependencies)
+# Base image: python:3.11-slim (Debian-based for nginx availability)
+#
+# Build command:
+# docker build -t rag-chatbot .
+#
+# Run command:
+# docker run -p 7860:7860 \
+# -e GEMINI_API_KEY=xxx \
+# -e HF_TOKEN=xxx \
+# rag-chatbot
+#
+# =============================================================================
+
+
+# =============================================================================
+# STAGE 1: FRONTEND DEPENDENCIES
+# =============================================================================
+# Purpose: Install all npm dependencies in an isolated stage
+# This stage uses Node.js Alpine for minimal footprint during dependency install.
+# The node_modules are passed to the builder stage; this stage is discarded.
+# =============================================================================
+FROM node:22-alpine AS frontend-deps
+
+# -----------------------------------------------------------------------------
+# Install System Dependencies
+# -----------------------------------------------------------------------------
+# libc6-compat: Required for some native Node.js modules that expect glibc
+# Alpine uses musl libc by default, and this compatibility layer helps with
+# packages that have native bindings compiled against glibc.
+# -----------------------------------------------------------------------------
+RUN apk add --no-cache libc6-compat
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Copy Package Files
+# -----------------------------------------------------------------------------
+# Copy only package.json and package-lock.json for optimal layer caching.
+# This layer is cached until these files change, avoiding unnecessary
+# reinstalls when only source code changes.
+# -----------------------------------------------------------------------------
+COPY frontend/package.json frontend/package-lock.json ./
+
+# -----------------------------------------------------------------------------
+# Install Dependencies
+# -----------------------------------------------------------------------------
+# npm ci (Clean Install) is preferred because:
+# - Faster: Uses exact versions from lock file
+# - Reproducible: Guarantees identical dependency tree
+# - Strict: Fails if lock file is out of sync
+# - Clean: Removes existing node_modules before install
+#
+# --prefer-offline: Use cached packages when available (faster in CI/CD)
+# Clean npm cache after install to reduce layer size.
+# -----------------------------------------------------------------------------
+RUN npm ci --prefer-offline && npm cache clean --force
+
+
+# =============================================================================
+# STAGE 2: FRONTEND BUILDER
+# =============================================================================
+# Purpose: Build the Next.js application with standalone output
+# This stage compiles TypeScript, bundles assets, and creates the minimal
+# standalone server that will be copied to the runtime image.
+# =============================================================================
+FROM node:22-alpine AS frontend-builder
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Copy Dependencies from frontend-deps Stage
+# -----------------------------------------------------------------------------
+# Reuse the installed node_modules to avoid reinstalling.
+# This ensures consistent dependencies across stages.
+# -----------------------------------------------------------------------------
+COPY --from=frontend-deps /app/node_modules ./node_modules
+
+# -----------------------------------------------------------------------------
+# Copy Frontend Source Code
+# -----------------------------------------------------------------------------
+# Copy all frontend files needed for the build.
+# The .dockerignore in frontend/ should exclude node_modules, .next, etc.
+# -----------------------------------------------------------------------------
+COPY frontend/ .
+
+# -----------------------------------------------------------------------------
+# Build-Time Environment Variables
+# -----------------------------------------------------------------------------
+# NEXT_PUBLIC_API_URL: Empty string = same-origin requests via nginx
+# The frontend will use relative URLs like /api/query, and nginx will
+# proxy these to the backend. This avoids CORS and simplifies deployment.
+#
+# NEXT_TELEMETRY_DISABLED: Prevent telemetry during build
+#
+# NODE_ENV: Production mode for optimized output
+# -----------------------------------------------------------------------------
+ENV NEXT_PUBLIC_API_URL=""
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV=production
+
+# -----------------------------------------------------------------------------
+# Build the Application
+# -----------------------------------------------------------------------------
+# Run Next.js production build. The --webpack flag ensures compatibility
+# with the custom webpack configuration in next.config.ts.
+#
+# Output structure:
+# .next/standalone/ - Minimal Node.js server with bundled deps
+# .next/static/ - Static assets (JS, CSS chunks)
+# public/ - Static files (images, fonts)
+# -----------------------------------------------------------------------------
+RUN npx next build --webpack
+
+
+# =============================================================================
+# STAGE 3: BACKEND BUILDER
+# =============================================================================
+# Purpose: Use Poetry to export serve-only dependencies to requirements.txt
+# This stage is discarded after generating the requirements file.
+# We don't need torch, sentence-transformers, or build tools in production.
+# =============================================================================
+FROM python:3.11-slim AS backend-builder
+
+# -----------------------------------------------------------------------------
+# Install Poetry
+# -----------------------------------------------------------------------------
+# Poetry manages Python dependencies. We use the official installer for
+# proper isolation. The version is pinned for reproducible builds.
+# -----------------------------------------------------------------------------
+ENV POETRY_VERSION=1.8.2
+ENV POETRY_HOME=/opt/poetry
+ENV PATH="${POETRY_HOME}/bin:${PATH}"
+
+# Install Poetry using the official installer script
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && curl -sSL https://install.python-poetry.org | python3 - \
+ && apt-get purge -y curl \
+ && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/*
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /build
+
+# -----------------------------------------------------------------------------
+# Copy Dependency Files
+# -----------------------------------------------------------------------------
+# Copy only pyproject.toml and poetry.lock for dependency resolution.
+# This layer is cached until these files change.
+# -----------------------------------------------------------------------------
+COPY pyproject.toml poetry.lock ./
+
+# -----------------------------------------------------------------------------
+# Export Requirements
+# -----------------------------------------------------------------------------
+# Export only main + serve dependencies (no dense, build, or dev groups).
+#
+# INCLUDED:
+# - Core: pydantic, numpy, httpx, tiktoken, rank-bm25, faiss-cpu, etc.
+# - Serve: fastapi, uvicorn, sse-starlette
+#
+# EXCLUDED:
+# - Dense: torch, sentence-transformers (too large, not needed for CPU serving)
+# - Build: pymupdf4llm, pyarrow, datasets (offline pipeline only)
+# - Dev: mypy, ruff, pytest (development tools only)
+#
+# --without-hashes: Required for pip compatibility
+# -----------------------------------------------------------------------------
+RUN poetry export --only main,serve --without-hashes -f requirements.txt -o requirements.txt
+
+
+# =============================================================================
+# STAGE 4: RUNTIME
+# =============================================================================
+# Purpose: Final production image with nginx, Node.js, and Python
+# This image serves both frontend and backend through nginx reverse proxy.
+#
+# Components:
+# - nginx: Reverse proxy on port 7860
+# - Node.js: Runs Next.js standalone server on port 3000
+# - Python: Runs FastAPI/uvicorn on port 8000
+#
+# Security:
+# - Non-root nginx worker processes
+# - Minimal installed packages
+# - No build tools or development dependencies
+# =============================================================================
+FROM python:3.11-slim AS runtime
+
+# -----------------------------------------------------------------------------
+# Environment Variables
+# -----------------------------------------------------------------------------
+# PYTHONDONTWRITEBYTECODE: Don't write .pyc files (reduces image size)
+# PYTHONUNBUFFERED: Ensure logs appear immediately in container output
+# PYTHONPATH: Add src/ to Python path for module imports
+# NODE_ENV: Production mode for Node.js
+# -----------------------------------------------------------------------------
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONPATH=/app/backend/src
+ENV NODE_ENV=production
+
+# -----------------------------------------------------------------------------
+# Install System Dependencies
+# -----------------------------------------------------------------------------
+# nginx: Reverse proxy server
+# curl: Health checks
+# supervisor: Process manager to run multiple services
+# gnupg: Required for adding NodeSource GPG key
+# ca-certificates: SSL certificates for HTTPS
+#
+# Node.js 22.x is installed from NodeSource repository for Debian.
+# This provides an up-to-date Node.js version compatible with Next.js.
+# -----------------------------------------------------------------------------
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ nginx \
+ curl \
+ supervisor \
+ gnupg \
+ ca-certificates \
+ # Add NodeSource repository for Node.js 22.x
+ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ # Clean up apt cache to reduce image size
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# -----------------------------------------------------------------------------
+# Create Application User
+# -----------------------------------------------------------------------------
+# Create a non-root user for running the application services.
+# Using UID/GID 1000 for compatibility with common host systems.
+#
+# Note: nginx master process runs as root (required for port binding),
+# but worker processes run as www-data (configured in nginx.conf).
+# -----------------------------------------------------------------------------
+RUN groupadd --gid 1000 appgroup \
+ && useradd --uid 1000 --gid appgroup --shell /bin/false --no-create-home appuser
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Copy Python Requirements from Backend Builder
+# -----------------------------------------------------------------------------
+# The requirements.txt was generated by Poetry and contains only serve deps.
+# -----------------------------------------------------------------------------
+COPY --from=backend-builder /build/requirements.txt ./requirements.txt
+
+# -----------------------------------------------------------------------------
+# Install Python Dependencies
+# -----------------------------------------------------------------------------
+# Install all serve dependencies using pip.
+# Flags:
+# --no-cache-dir: Don't cache packages (reduces image size)
+# --no-compile: Don't compile .py to .pyc (PYTHONDONTWRITEBYTECODE handles this)
+# -----------------------------------------------------------------------------
+RUN pip install --no-cache-dir --no-compile -r requirements.txt
+
+# -----------------------------------------------------------------------------
+# Copy Backend Source Code
+# -----------------------------------------------------------------------------
+# Copy the Python source code to /app/backend/src/
+# PYTHONPATH=/app/backend/src allows importing rag_chatbot module
+# -----------------------------------------------------------------------------
+COPY src/ /app/backend/src/
+
+# -----------------------------------------------------------------------------
+# Copy Frontend Standalone Build
+# -----------------------------------------------------------------------------
+# Copy the Next.js standalone output from the frontend-builder stage.
+# Structure:
+# /app/frontend/server.js - Next.js production server
+# /app/frontend/.next/ - Compiled application
+# /app/frontend/node_modules/ - Minimal runtime dependencies
+# /app/frontend/public/ - Static assets
+# -----------------------------------------------------------------------------
+COPY --from=frontend-builder /app/public /app/frontend/public
+COPY --from=frontend-builder /app/.next/standalone /app/frontend
+COPY --from=frontend-builder /app/.next/static /app/frontend/.next/static
+
+# -----------------------------------------------------------------------------
+# Create nginx Configuration
+# -----------------------------------------------------------------------------
+# Configure nginx as reverse proxy:
+# - Listen on port 7860 (HuggingFace Spaces requirement)
+# - Proxy /api/* requests to backend on port 8000
+# - Proxy all other requests to frontend on port 3000
+# - Enable gzip compression for text content
+# - Configure appropriate timeouts for SSE streaming
+# -----------------------------------------------------------------------------
+RUN cat > /etc/nginx/nginx.conf << 'EOF'
+# =============================================================================
+# nginx Configuration for RAG Chatbot
+# =============================================================================
+# This configuration sets up nginx as a reverse proxy for the combined
+# frontend (Next.js) and backend (FastAPI) application.
+# =============================================================================
+
+# Run nginx master process as root (required for port binding)
+# Worker processes run as www-data for security
+user www-data;
+
+# Auto-detect number of CPU cores for worker processes
+worker_processes auto;
+
+# Error log location and level
+error_log /var/log/nginx/error.log warn;
+
+# PID file location
+pid /var/run/nginx.pid;
+
+# -----------------------------------------------------------------------------
+# Events Configuration
+# -----------------------------------------------------------------------------
+events {
+ # Maximum simultaneous connections per worker
+ worker_connections 1024;
+
+ # Use epoll for better performance on Linux
+ use epoll;
+
+ # Accept multiple connections at once
+ multi_accept on;
+}
+
+# -----------------------------------------------------------------------------
+# HTTP Configuration
+# -----------------------------------------------------------------------------
+http {
+ # Include MIME types for proper content-type headers
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ # Logging format
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ # Performance optimizations
+ sendfile on;
+ tcp_nopush on;
+ tcp_nodelay on;
+
+ # Keep-alive settings
+ keepalive_timeout 65;
+
+ # Gzip compression for text content
+ gzip on;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 6;
+ gzip_types text/plain text/css text/xml application/json application/javascript
+ application/xml application/xml+rss text/javascript application/x-javascript;
+
+ # Upstream definitions for backend and frontend
+ upstream backend {
+ server 127.0.0.1:8000;
+ keepalive 32;
+ }
+
+ upstream frontend {
+ server 127.0.0.1:3000;
+ keepalive 32;
+ }
+
+ # -------------------------------------------------------------------------
+ # Main Server Block
+ # -------------------------------------------------------------------------
+ server {
+ # Listen on port 7860 (HuggingFace Spaces requirement)
+ listen 7860;
+ server_name _;
+
+ # Client body size limit (for potential file uploads)
+ client_max_body_size 10M;
+
+ # ---------------------------------------------------------------------
+ # Backend API Routes
+ # ---------------------------------------------------------------------
+ # Proxy all /api/* requests to the FastAPI backend
+ # This includes /api/query, /api/health, etc.
+ # ---------------------------------------------------------------------
+ location /api/ {
+ proxy_pass http://backend/;
+ proxy_http_version 1.1;
+
+ # Headers for proper proxying
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Connection upgrade for WebSocket (if needed in future)
+ proxy_set_header Connection "";
+
+ # Timeouts for long-running requests (SSE streaming)
+ # These are generous to support streaming LLM responses
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+
+ # Disable buffering for SSE (Server-Sent Events)
+ # This ensures streaming responses are sent immediately
+ proxy_buffering off;
+ proxy_cache off;
+
+ # SSE-specific headers
+ proxy_set_header Accept-Encoding "";
+ }
+
+ # ---------------------------------------------------------------------
+ # Health Check Endpoint (direct to backend)
+ # ---------------------------------------------------------------------
+ # Allow direct access to health endpoints for container orchestration
+ # ---------------------------------------------------------------------
+ location /health {
+ proxy_pass http://backend/health;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Connection "";
+ }
+
+ # ---------------------------------------------------------------------
+ # Frontend Routes (catch-all)
+ # ---------------------------------------------------------------------
+ # Proxy all other requests to the Next.js frontend
+ # This includes pages, static assets, and _next/* resources
+ # ---------------------------------------------------------------------
+ location / {
+ proxy_pass http://frontend;
+ proxy_http_version 1.1;
+
+ # Headers for proper proxying
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+
+ # Standard timeouts for frontend
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+ }
+
+ # ---------------------------------------------------------------------
+ # Static Files Caching
+ # ---------------------------------------------------------------------
+ # Cache static assets with long expiry for performance
+ # Next.js includes content hashes in filenames, so this is safe
+ # ---------------------------------------------------------------------
+ location /_next/static {
+ proxy_pass http://frontend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Connection "";
+
+ # Cache static assets for 1 year (immutable content-hashed files)
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+ }
+}
+EOF
+
+# -----------------------------------------------------------------------------
+# Create Supervisor Configuration
+# -----------------------------------------------------------------------------
+# Supervisor manages multiple processes in a single container:
+# - nginx: Reverse proxy (master process)
+# - frontend: Next.js server (node process)
+# - backend: FastAPI/uvicorn (python process)
+#
+# All processes are started together and supervisor monitors their health.
+# If any process crashes, supervisor will restart it automatically.
+# -----------------------------------------------------------------------------
+RUN cat > /etc/supervisor/conf.d/supervisord.conf << 'EOF'
+; =============================================================================
+; Supervisor Configuration for RAG Chatbot
+; =============================================================================
+; Manages nginx, frontend (Next.js), and backend (FastAPI) processes.
+; All processes run in the foreground (nodaemon=true).
+; =============================================================================
+
+[supervisord]
+nodaemon=true
+user=root
+logfile=/var/log/supervisor/supervisord.log
+pidfile=/var/run/supervisord.pid
+childlogdir=/var/log/supervisor
+
+; -----------------------------------------------------------------------------
+; nginx - Reverse Proxy
+; -----------------------------------------------------------------------------
+; nginx master process must run as root for port binding.
+; Worker processes run as www-data (configured in nginx.conf).
+; -----------------------------------------------------------------------------
+[program:nginx]
+command=/usr/sbin/nginx -g "daemon off;"
+autostart=true
+autorestart=true
+priority=10
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+; -----------------------------------------------------------------------------
+; Frontend - Next.js Server
+; -----------------------------------------------------------------------------
+; Runs the Next.js standalone server on port 3000.
+; The standalone server is minimal and includes only required dependencies.
+; -----------------------------------------------------------------------------
+[program:frontend]
+command=node /app/frontend/server.js
+directory=/app/frontend
+autostart=true
+autorestart=true
+priority=20
+user=appuser
+environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0"
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+; -----------------------------------------------------------------------------
+; Backend - FastAPI/Uvicorn Server
+; -----------------------------------------------------------------------------
+; Runs the FastAPI application on port 8000.
+; Uses the app factory pattern for proper initialization.
+;
+; LLM Provider API Keys (inherited from container environment):
+; - GEMINI_API_KEY: Google Gemini API (primary provider)
+; - DEEPSEEK_API_KEY: DeepSeek API (fallback provider)
+; - ANTHROPIC_API_KEY: Anthropic API (fallback provider)
+; - GROQ_API_KEY: Groq API (optional provider)
+; - HF_TOKEN: HuggingFace token for dataset access and logging
+;
+; Note: The environment= directive ADDS to the inherited environment.
+; API keys set via "docker run -e" or HuggingFace Spaces secrets are
+; automatically passed through to this process without explicit listing.
+; -----------------------------------------------------------------------------
+[program:backend]
+command=python -m uvicorn rag_chatbot.api.main:create_app --factory --host 0.0.0.0 --port 8000
+directory=/app/backend
+autostart=true
+autorestart=true
+priority=30
+user=appuser
+environment=PYTHONPATH="/app/backend/src",PYTHONDONTWRITEBYTECODE="1",PYTHONUNBUFFERED="1"
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+EOF
+
+# -----------------------------------------------------------------------------
+# Create Log Directories
+# -----------------------------------------------------------------------------
+# Create directories for nginx and supervisor logs with proper permissions.
+# -----------------------------------------------------------------------------
+RUN mkdir -p /var/log/nginx /var/log/supervisor \
+ && chown -R www-data:www-data /var/log/nginx \
+ && chmod -R 755 /var/log/supervisor
+
+# -----------------------------------------------------------------------------
+# Set Permissions
+# -----------------------------------------------------------------------------
+# Ensure appuser can read application files and write to necessary locations.
+# nginx runs as www-data, frontend/backend run as appuser.
+# -----------------------------------------------------------------------------
+RUN chown -R appuser:appgroup /app/frontend /app/backend
+
+# -----------------------------------------------------------------------------
+# Expose Port
+# -----------------------------------------------------------------------------
+# HuggingFace Spaces expects the application to listen on port 7860.
+# All traffic flows through nginx on this port.
+# -----------------------------------------------------------------------------
+EXPOSE 7860
+
+# -----------------------------------------------------------------------------
+# Health Check
+# -----------------------------------------------------------------------------
+# Docker health check for container orchestration.
+# Checks the /health endpoint via nginx, which proxies to the backend.
+#
+# Options:
+# --interval=30s: Check every 30 seconds
+# --timeout=10s: Wait up to 10 seconds for response
+# --start-period=60s: Grace period for all services to start
+# --retries=3: Mark unhealthy after 3 consecutive failures
+#
+# Note: Using /health/ready because it returns 200 when ready, 503 when loading.
+# The start-period is longer (60s) because we need nginx + frontend + backend
+# to all be ready before the health check can pass.
+# -----------------------------------------------------------------------------
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD curl --fail http://localhost:7860/health/ready || exit 1
+
+# -----------------------------------------------------------------------------
+# Entrypoint
+# -----------------------------------------------------------------------------
+# Start supervisor, which manages all three services:
+# 1. nginx (reverse proxy on port 7860)
+# 2. frontend (Next.js on port 3000)
+# 3. backend (FastAPI on port 8000)
+#
+# Supervisor runs in the foreground (nodaemon=true) as the main process.
+# It monitors all child processes and restarts them if they crash.
+#
+# Environment variables (GEMINI_API_KEY, HF_TOKEN, etc.) should be passed
+# at runtime via docker run -e or configured in HuggingFace Spaces secrets.
+# Supervisor passes them through to the backend process.
+# -----------------------------------------------------------------------------
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
diff --git a/Dockerfile.backend b/Dockerfile.backend
new file mode 100644
index 0000000000000000000000000000000000000000..2f45004ca2c290c399ad162934332c4658bcf425
--- /dev/null
+++ b/Dockerfile.backend
@@ -0,0 +1,225 @@
+# =============================================================================
+# RAG Chatbot Backend - Production Dockerfile
+# =============================================================================
+# This is a multi-stage Dockerfile optimized for HuggingFace Spaces deployment.
+# It creates a minimal runtime image that excludes heavy build-time dependencies
+# like torch, sentence-transformers, and pyarrow.
+#
+# Architecture:
+# Stage 1 (builder): Installs Poetry and exports serve-only dependencies
+# Stage 2 (runtime): Minimal image with only runtime dependencies
+#
+# Target image size: < 2GB
+# Base image: python:3.11-slim (Debian-based, ~150MB)
+# =============================================================================
+
+# =============================================================================
+# STAGE 1: BUILDER
+# =============================================================================
+# Purpose: Use Poetry to export a clean requirements.txt for serve dependencies
+# This stage is discarded after build, so Poetry overhead doesn't affect runtime
+# =============================================================================
+FROM python:3.11-slim AS builder
+
+# -----------------------------------------------------------------------------
+# Install Poetry
+# -----------------------------------------------------------------------------
+# Poetry is used to manage dependencies and export requirements.txt
+# We pin the version for reproducible builds
+# Using pipx isolation avoids polluting the system Python
+# -----------------------------------------------------------------------------
+ENV POETRY_VERSION=1.8.2
+ENV POETRY_HOME=/opt/poetry
+ENV PATH="${POETRY_HOME}/bin:${PATH}"
+
+# Install Poetry using the official installer script
+# This method is recommended over pip install for isolation
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && curl -sSL https://install.python-poetry.org | python3 - \
+ && apt-get purge -y curl \
+ && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/*
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /build
+
+# -----------------------------------------------------------------------------
+# Copy Dependency Files
+# -----------------------------------------------------------------------------
+# Copy only the files needed for dependency resolution
+# This layer is cached until pyproject.toml or poetry.lock changes
+# -----------------------------------------------------------------------------
+COPY pyproject.toml poetry.lock ./
+
+# -----------------------------------------------------------------------------
+# Export Requirements
+# -----------------------------------------------------------------------------
+# Export only the serve group dependencies to requirements.txt
+# Flags explained:
+# --only main,serve : Include core deps + serve group (FastAPI, uvicorn, SSE)
+# --without-hashes : Omit hashes for compatibility with pip install
+# -f requirements.txt: Output to requirements.txt file
+#
+# IMPORTANT: This excludes:
+# - dense group (torch, sentence-transformers) - not needed at runtime
+# - build group (pymupdf4llm, pyarrow, etc.) - offline pipeline only
+# - dev group (mypy, ruff, pytest) - development tools only
+# -----------------------------------------------------------------------------
+RUN poetry export --only main,serve --without-hashes -f requirements.txt -o requirements.txt
+
+# =============================================================================
+# STAGE 2: RUNTIME
+# =============================================================================
+# Purpose: Minimal production image with only serve dependencies
+# This is the final image that runs on HuggingFace Spaces
+# =============================================================================
+FROM python:3.11-slim AS runtime
+
+# -----------------------------------------------------------------------------
+# Environment Variables
+# -----------------------------------------------------------------------------
+# PYTHONDONTWRITEBYTECODE: Prevents Python from writing .pyc bytecode files
+# - Reduces image size slightly
+# - Avoids permission issues with read-only filesystems
+#
+# PYTHONUNBUFFERED: Forces stdout/stderr to be unbuffered
+# - Ensures log messages appear immediately in container logs
+# - Critical for debugging and monitoring in production
+#
+# PYTHONPATH: Adds src/ to Python's module search path
+# - Allows importing rag_chatbot package without installation
+# - Matches the src layout convention used in the project
+# -----------------------------------------------------------------------------
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONPATH=/app/src
+
+# -----------------------------------------------------------------------------
+# Create Non-Root User
+# -----------------------------------------------------------------------------
+# Security best practice: Run the application as a non-root user
+# This limits the impact of potential security vulnerabilities
+#
+# - Create group 'appgroup' with GID 1000
+# - Create user 'appuser' with UID 1000 in that group
+# - No home directory needed (-M), no login shell (-s /bin/false)
+# -----------------------------------------------------------------------------
+RUN groupadd --gid 1000 appgroup \
+ && useradd --uid 1000 --gid appgroup --shell /bin/false --no-create-home appuser
+
+# -----------------------------------------------------------------------------
+# Install System Dependencies
+# -----------------------------------------------------------------------------
+# Install curl for health checks and clean up apt cache to reduce image size
+# The health check endpoint will be probed using curl
+# -----------------------------------------------------------------------------
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Copy Requirements from Builder Stage
+# -----------------------------------------------------------------------------
+# The requirements.txt was generated by Poetry in the builder stage
+# It contains only the serve dependencies (no torch, no build deps)
+# -----------------------------------------------------------------------------
+COPY --from=builder /build/requirements.txt .
+
+# -----------------------------------------------------------------------------
+# Install Python Dependencies
+# -----------------------------------------------------------------------------
+# Install all serve dependencies using pip
+# Flags explained:
+# --no-cache-dir : Don't cache pip packages (reduces image size)
+# --no-compile : Don't compile .py to .pyc (PYTHONDONTWRITEBYTECODE=1 handles this)
+# -r requirements.txt : Install from the exported requirements
+#
+# This installs:
+# - Core deps: pydantic, numpy, httpx, tiktoken, rank-bm25, faiss-cpu, etc.
+# - Serve deps: fastapi, uvicorn, sse-starlette
+#
+# This does NOT install:
+# - torch, sentence-transformers (dense group)
+# - pymupdf4llm, pyarrow, datasets (build group)
+# - mypy, ruff, pytest (dev group)
+# -----------------------------------------------------------------------------
+RUN pip install --no-cache-dir --no-compile -r requirements.txt
+
+# -----------------------------------------------------------------------------
+# Copy Application Source Code
+# -----------------------------------------------------------------------------
+# Copy the source code from the host to the container
+# The src/rag_chatbot/ directory contains the application package
+# PYTHONPATH=/app/src allows Python to find the rag_chatbot module
+# -----------------------------------------------------------------------------
+COPY src/ /app/src/
+
+# -----------------------------------------------------------------------------
+# Set Ownership
+# -----------------------------------------------------------------------------
+# Change ownership of the application directory to the non-root user
+# This ensures the application can read its own files when running as appuser
+# -----------------------------------------------------------------------------
+RUN chown -R appuser:appgroup /app
+
+# -----------------------------------------------------------------------------
+# Switch to Non-Root User
+# -----------------------------------------------------------------------------
+# From this point on, all commands run as appuser (UID 1000)
+# This is the user that will run the application in production
+# -----------------------------------------------------------------------------
+USER appuser
+
+# -----------------------------------------------------------------------------
+# Expose Port
+# -----------------------------------------------------------------------------
+# HuggingFace Spaces expects the application to listen on port 7860
+# This documents the port but doesn't actually publish it (done at runtime)
+# -----------------------------------------------------------------------------
+EXPOSE 7860
+
+# -----------------------------------------------------------------------------
+# Health Check
+# -----------------------------------------------------------------------------
+# Docker health check configuration for container orchestration
+# This allows Docker/HF Spaces to know if the application is healthy
+#
+# --interval=30s : Check every 30 seconds
+# --timeout=10s : Wait up to 10 seconds for response
+# --start-period=40s : Grace period for startup (resources load lazily on first request)
+# --retries=3 : Mark unhealthy after 3 consecutive failures
+#
+# Health endpoint paths:
+# /health/ready - Simple readiness probe (200 OK when ready, 503 when loading)
+# /health/health - Full health status with version and component details
+#
+# Note: Using /health/ready because it returns 200/503 which curl --fail can detect.
+# The endpoint returns 503 while resources are loading, which is expected during
+# the start period. Once resources load (on first request), it returns 200.
+# -----------------------------------------------------------------------------
+HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
+ CMD curl --fail http://localhost:7860/health/ready || exit 1
+
+# -----------------------------------------------------------------------------
+# Entrypoint
+# -----------------------------------------------------------------------------
+# Start the FastAPI application using uvicorn ASGI server
+# Command breakdown:
+# uvicorn : ASGI server for async Python web apps
+# rag_chatbot.api.main:create_app : Module path to the app factory function
+# --factory : create_app is a factory function, not an app instance
+# --host 0.0.0.0 : Listen on all network interfaces (required for containers)
+# --port 7860 : HuggingFace Spaces default port
+#
+# Using CMD allows the command to be overridden for debugging if needed
+# Using exec form (JSON array) ensures proper signal handling
+# -----------------------------------------------------------------------------
+CMD ["uvicorn", "rag_chatbot.api.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "7860"]
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
new file mode 100644
index 0000000000000000000000000000000000000000..d367aed09f20aa886759e1b15a3caa969a85e00c
--- /dev/null
+++ b/Dockerfile.frontend
@@ -0,0 +1,353 @@
+# =============================================================================
+# RAG Chatbot Frontend - Production Dockerfile
+# =============================================================================
+# This is a multi-stage Dockerfile optimized for production deployment of the
+# Next.js frontend application on HuggingFace Spaces or any Docker-based hosting.
+#
+# Architecture (3 stages):
+# Stage 1 (deps): Install all dependencies using npm ci
+# Stage 2 (builder): Build the Next.js application with standalone output
+# Stage 3 (runner): Minimal production image with only runtime files
+#
+# Key optimizations:
+# - Alpine-based images for minimal size
+# - Multi-stage build eliminates build-time dependencies from final image
+# - Standalone output mode bundles only required node_modules
+# - Gzip compression enabled via Node.js flags
+# - Non-root user for security
+#
+# Target image size: < 500MB
+# Base image: node:22-alpine (~50MB base)
+#
+# Build command:
+# docker build -f Dockerfile.frontend -t frontend ./frontend
+#
+# Run command:
+# docker run -p 3000:3000 frontend
+#
+# =============================================================================
+
+
+# =============================================================================
+# STAGE 1: DEPENDENCIES
+# =============================================================================
+# Purpose: Install all npm dependencies in an isolated stage
+# This stage installs both production and dev dependencies because we need
+# devDependencies (TypeScript, Tailwind, etc.) to build the application.
+# The final image will not include these - only the standalone output.
+# =============================================================================
+FROM node:22-alpine AS deps
+
+# -----------------------------------------------------------------------------
+# Install System Dependencies
+# -----------------------------------------------------------------------------
+# libc6-compat: Required for some native Node.js modules that expect glibc
+# Alpine uses musl libc by default, and this compatibility layer helps with
+# packages that have native bindings compiled against glibc.
+# -----------------------------------------------------------------------------
+RUN apk add --no-cache libc6-compat
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+# Use /app as the standard working directory for the application
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Copy Package Files
+# -----------------------------------------------------------------------------
+# Copy only package.json and package-lock.json first for better layer caching.
+# Docker caches this layer until these files change, avoiding unnecessary
+# reinstalls when only source code changes.
+# -----------------------------------------------------------------------------
+COPY package.json package-lock.json ./
+
+# -----------------------------------------------------------------------------
+# Install Dependencies
+# -----------------------------------------------------------------------------
+# npm ci (Clean Install) is used instead of npm install because:
+# - It's faster: skips package resolution, uses exact versions from lock file
+# - It's reproducible: guarantees exact same dependency tree
+# - It's stricter: fails if lock file is out of sync with package.json
+# - It clears node_modules before install: ensures clean state
+#
+# --prefer-offline: Use cached packages when available (faster in CI/CD)
+#
+# After install, clean npm cache to reduce layer size.
+# -----------------------------------------------------------------------------
+RUN npm ci --prefer-offline && npm cache clean --force
+
+
+# =============================================================================
+# STAGE 2: BUILDER
+# =============================================================================
+# Purpose: Build the Next.js application for production
+# This stage:
+# 1. Copies dependencies from the deps stage
+# 2. Copies source code
+# 3. Runs the production build
+# 4. Outputs standalone server + static assets
+#
+# The standalone output (enabled in next.config.ts) creates:
+# - .next/standalone/ - Minimal Node.js server with bundled dependencies
+# - .next/static/ - Static assets (JS, CSS chunks)
+# - public/ - Public static files (images, fonts, etc.)
+# =============================================================================
+FROM node:22-alpine AS builder
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Copy Dependencies from deps Stage
+# -----------------------------------------------------------------------------
+# Copy the fully installed node_modules from the deps stage.
+# This is faster than reinstalling and ensures consistent dependencies.
+# -----------------------------------------------------------------------------
+COPY --from=deps /app/node_modules ./node_modules
+
+# -----------------------------------------------------------------------------
+# Copy Source Code and Configuration
+# -----------------------------------------------------------------------------
+# Copy all source files needed for the build.
+# Note: Files matching patterns in .dockerignore are excluded automatically.
+#
+# The key files needed:
+# - src/ : Application source code (pages, components, etc.)
+# - public/ : Static assets to be copied to output
+# - package.json : Project metadata and scripts
+# - next.config.ts : Next.js configuration (standalone output enabled)
+# - tsconfig.json : TypeScript configuration
+# - postcss.config.mjs, tailwind config : CSS processing
+# -----------------------------------------------------------------------------
+COPY . .
+
+# -----------------------------------------------------------------------------
+# Build-Time Environment Variables
+# -----------------------------------------------------------------------------
+# NEXT_PUBLIC_API_URL: The base URL for API requests
+# - Set at build time because NEXT_PUBLIC_* vars are inlined into the bundle
+# - Empty string = same-origin requests (frontend and backend on same domain)
+# - Set to absolute URL if backend is on different domain
+#
+# NEXT_TELEMETRY_DISABLED: Disable Next.js anonymous telemetry
+# - Prevents outbound network requests during build
+# - Recommended for CI/CD and production environments
+#
+# NODE_ENV: Set to production for optimized build output
+# - Enables production optimizations (minification, dead code elimination)
+# - Disables development-only features and warnings
+# -----------------------------------------------------------------------------
+ENV NEXT_PUBLIC_API_URL=""
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV=production
+
+# -----------------------------------------------------------------------------
+# Build the Application
+# -----------------------------------------------------------------------------
+# Run the Next.js production build using webpack bundler.
+#
+# Note: Next.js 16+ uses Turbopack by default, but we use --webpack flag here
+# because the next.config.ts contains a webpack configuration block.
+# Using webpack ensures compatibility with existing configuration.
+#
+# This command will:
+# 1. Compile TypeScript to JavaScript
+# 2. Bundle and optimize client-side code using webpack
+# 3. Pre-render static pages (if any)
+# 4. Generate standalone output in .next/standalone/
+# 5. Generate static assets in .next/static/
+#
+# The standalone output includes:
+# - server.js: Minimal Node.js server
+# - node_modules: Only production dependencies needed by the server
+# - Required Next.js internal files
+# -----------------------------------------------------------------------------
+RUN npx next build --webpack
+
+
+# =============================================================================
+# STAGE 3: RUNNER (Production)
+# =============================================================================
+# Purpose: Minimal production image containing only what's needed to run
+# This is the final image that will be deployed.
+#
+# Size optimization:
+# - Alpine base image (~50MB vs ~350MB for Debian)
+# - Only standalone output copied (no full node_modules)
+# - No build tools, devDependencies, or source TypeScript
+#
+# Security:
+# - Runs as non-root user (nextjs)
+# - Minimal attack surface
+# - No unnecessary packages or tools
+# =============================================================================
+FROM node:22-alpine AS runner
+
+# -----------------------------------------------------------------------------
+# Set Working Directory
+# -----------------------------------------------------------------------------
+WORKDIR /app
+
+# -----------------------------------------------------------------------------
+# Environment Variables for Production
+# -----------------------------------------------------------------------------
+# NODE_ENV: production
+# - Next.js uses this to enable production optimizations
+# - Disables development-only features
+#
+# NEXT_TELEMETRY_DISABLED: Disable telemetry at runtime
+# - No anonymous usage data sent to Vercel
+#
+# PORT: The port the server will listen on
+# - Default 3000, can be overridden at runtime
+# - HuggingFace Spaces may require specific ports (e.g., 7860)
+#
+# HOSTNAME: The hostname to bind to
+# - 0.0.0.0 binds to all network interfaces
+# - Required for Docker networking (localhost wouldn't be accessible)
+#
+# NODE_OPTIONS: Node.js runtime flags
+# - --enable-source-maps: Better error stack traces in production
+# - Note: Gzip compression is handled by Next.js automatically
+# (via the compress option, which defaults to true)
+# -----------------------------------------------------------------------------
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV PORT=3000
+ENV HOSTNAME=0.0.0.0
+ENV NODE_OPTIONS="--enable-source-maps"
+
+# -----------------------------------------------------------------------------
+# Create Non-Root User
+# -----------------------------------------------------------------------------
+# Security best practice: Run applications as non-root user.
+# This limits the potential damage from security vulnerabilities.
+#
+# addgroup: Create a system group 'nodejs' with GID 1001
+# adduser: Create a system user 'nextjs' with UID 1001
+# - -S: Create a system user (no password, no home dir contents)
+# - -G: Add to the nodejs group
+# - -u: Set specific UID for consistency across environments
+#
+# Using UID/GID 1001 to avoid conflicts with existing system users.
+# -----------------------------------------------------------------------------
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 --ingroup nodejs nextjs
+
+# -----------------------------------------------------------------------------
+# Copy Public Directory
+# -----------------------------------------------------------------------------
+# Copy static files from the public directory.
+# These files are served directly by Next.js without processing.
+# Common contents: favicon.ico, robots.txt, static images, fonts
+#
+# --chown: Set ownership to nextjs user for proper permissions
+# -----------------------------------------------------------------------------
+COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+
+# -----------------------------------------------------------------------------
+# Create .next Directory
+# -----------------------------------------------------------------------------
+# Create the .next directory with proper ownership.
+# Next.js writes cache and runtime files here.
+# Pre-creating with correct ownership prevents permission errors.
+# -----------------------------------------------------------------------------
+RUN mkdir -p .next && chown nextjs:nodejs .next
+
+# -----------------------------------------------------------------------------
+# Copy Standalone Server
+# -----------------------------------------------------------------------------
+# Copy the standalone output from the builder stage.
+# This is the minimal Node.js server with bundled dependencies.
+#
+# Structure of .next/standalone/:
+# - server.js : The entry point for the production server
+# - node_modules/ : Only the dependencies required by server.js
+# - .next/ : Compiled server components and routes
+# - package.json : Minimal package info
+#
+# The standalone output is significantly smaller than full node_modules
+# because it only includes the specific files needed for production.
+# -----------------------------------------------------------------------------
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+
+# -----------------------------------------------------------------------------
+# Copy Static Assets
+# -----------------------------------------------------------------------------
+# Copy the static assets directory to the standalone output.
+# These are the compiled JavaScript/CSS chunks and other static files.
+#
+# IMPORTANT: The standalone output does NOT automatically include .next/static
+# because these files should typically be served by a CDN. However, for
+# HuggingFace Spaces and simple deployments, we serve them from the same server.
+#
+# The static directory must be placed inside .next/ for Next.js to find it.
+# -----------------------------------------------------------------------------
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+# -----------------------------------------------------------------------------
+# Switch to Non-Root User
+# -----------------------------------------------------------------------------
+# All subsequent commands and the container runtime will use this user.
+# This is the final security measure before running the application.
+# -----------------------------------------------------------------------------
+USER nextjs
+
+# -----------------------------------------------------------------------------
+# Expose Port
+# -----------------------------------------------------------------------------
+# Document the port that the application listens on.
+# This is informational; actual port mapping is done at runtime with -p flag.
+# The PORT environment variable (default 3000) controls the actual binding.
+# -----------------------------------------------------------------------------
+EXPOSE 3000
+
+# -----------------------------------------------------------------------------
+# Health Check
+# -----------------------------------------------------------------------------
+# Docker health check to verify the container is running properly.
+# Used by orchestrators (Docker Compose, Kubernetes, HF Spaces) for:
+# - Container lifecycle management
+# - Load balancer health checks
+# - Automatic container restarts on failure
+#
+# Options:
+# --interval=30s : Check every 30 seconds
+# --timeout=10s : Wait up to 10 seconds for response
+# --start-period=30s: Grace period for application startup
+# --retries=3 : Mark unhealthy after 3 consecutive failures
+#
+# Command:
+# wget: Use wget instead of curl (curl not installed in alpine by default)
+# --no-verbose: Reduce output noise
+# --tries=1: Single attempt per check
+# --spider: Don't download, just check availability
+# http://localhost:3000/: The root page (or use a dedicated health endpoint)
+# -----------------------------------------------------------------------------
+HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
+
+# -----------------------------------------------------------------------------
+# Start Command
+# -----------------------------------------------------------------------------
+# Start the Next.js production server using the standalone server.js file.
+#
+# The standalone server.js is a minimal Node.js server that:
+# - Handles all Next.js routing (pages, API routes, etc.)
+# - Serves static files from .next/static and public
+# - Includes production optimizations (compression, caching headers)
+# - Uses the HOSTNAME and PORT environment variables
+#
+# Using exec form (JSON array) ensures:
+# - Proper signal handling (SIGTERM reaches Node.js directly)
+# - No shell process overhead
+# - Correct argument parsing
+#
+# Note: The server automatically enables gzip compression via Next.js
+# (compress: true is the default in production mode).
+# -----------------------------------------------------------------------------
+CMD ["node", "server.js"]
diff --git a/PROJECT_README.md b/PROJECT_README.md
new file mode 100644
index 0000000000000000000000000000000000000000..2e8dfbdc504cdf8a03aea5a77120c2f040ba50f9
--- /dev/null
+++ b/PROJECT_README.md
@@ -0,0 +1,250 @@
+# RAG Chatbot for pythermalcomfort
+
+> **Note**: For HuggingFace Space configuration and user documentation, see [README.md](README.md).
+
+
+
+
+A Retrieval-Augmented Generation (RAG) chatbot for the pythermalcomfort library documentation. The chatbot uses hybrid retrieval (dense embeddings + BM25) to find relevant documentation chunks and streams responses via LLM providers.
+
+## Features
+
+- Hybrid retrieval combining dense embeddings (BGE) with BM25 keyword search
+- Multi-provider LLM support with automatic fallback (Gemini -> Groq -> DeepSeek)
+- Server-Sent Events (SSE) streaming for real-time responses
+- Structure-aware PDF chunking with heading inheritance
+- Async query logging to HuggingFace datasets
+
+## Installation
+
+### Prerequisites
+
+- Python 3.11 or higher
+- [Poetry](https://python-poetry.org/docs/#installation) for dependency management
+
+### Environment Setup
+
+1. Clone the repository:
+ ```bash
+ git clone https://github.com/sadickam/pythermalcomfort-chat.git
+ cd pythermalcomfort-chat
+ ```
+
+2. Create and activate a virtual environment:
+ ```bash
+ python -m venv therm_venv
+ source therm_venv/bin/activate # Linux/macOS
+ # or
+ therm_venv\Scripts\activate # Windows
+ ```
+
+3. Install dependencies using one of the install modes below.
+
+### Install Modes
+
+The project uses Poetry dependency groups to support different deployment scenarios:
+
+```bash
+# Server only (BM25 retrieval) - lightweight, no GPU required
+poetry install --with serve
+
+# Server with dense retrieval - requires torch, optional GPU
+poetry install --with serve,dense
+
+# Full build pipeline (local) - for rebuilding embeddings and indexes
+poetry install --with build,dev
+```
+
+### Dependency Comparison
+
+| Dependency | Core | Serve | Dense | Build |
+|------------------------|:----:|:-----:|:-----:|:-----:|
+| pydantic | x | | | |
+| pydantic-settings | x | | | |
+| numpy | x | | | |
+| httpx | x | | | |
+| tiktoken | x | | | |
+| rank-bm25 | x | | | |
+| faiss-cpu | x | | | |
+| fastapi | | x | | |
+| uvicorn | | x | | |
+| sse-starlette | | x | | |
+| sentence-transformers | | | x | x |
+| torch | | | x | x |
+| pymupdf4llm | | | | x |
+| pymupdf | | | | x |
+| pyarrow | | | | x |
+| datasets | | | | x |
+
+**Install mode explanations:**
+
+- **Core**: Always installed. Provides base functionality for retrieval and LLM communication.
+- **Serve** (`--with serve`): Adds FastAPI server for hosting the chatbot API. Suitable for CPU-only deployments using precomputed embeddings.
+- **Dense** (`--with dense`): Adds sentence-transformers and PyTorch for runtime embedding generation. Use when you need to embed queries with dense vectors.
+- **Build** (`--with build`): Full offline pipeline for processing PDFs, generating embeddings, and publishing indexes to HuggingFace.
+
+## Quick Start
+
+1. Set up environment variables:
+ ```bash
+ cp .env.example .env
+ # Edit .env with your API keys
+ ```
+
+2. Start the server:
+ ```bash
+ poetry run uvicorn src.rag_chatbot.api.main:app --reload
+ ```
+
+3. The API will be available at `http://localhost:8000`
+
+## Project Structure
+
+```
+src/rag_chatbot/
+├── api/ # FastAPI endpoints and middleware
+├── chunking/ # Structure-aware document chunking
+├── config/ # Settings and configuration
+├── embeddings/ # BGE encoder and storage
+├── extraction/ # PDF to Markdown conversion
+├── llm/ # LLM provider registry (Gemini, Groq, DeepSeek)
+├── qlog/ # Async query logging to HuggingFace
+└── retrieval/ # Hybrid retriever (FAISS + BM25 with RRF fusion)
+```
+
+## Build Pipeline
+
+For rebuilding embeddings and indexes from source PDFs:
+
+```bash
+# Full rebuild: extract -> chunk -> embed -> index -> publish
+poetry run python scripts/rebuild.py data/raw/
+
+# Individual steps
+poetry run python scripts/extract.py data/raw/ data/processed/
+poetry run python scripts/chunk.py data/processed/ data/chunks/chunks.jsonl
+poetry run python scripts/embed.py data/chunks/chunks.jsonl data/embeddings/ --publish
+```
+
+## Development
+
+### Running Tests
+
+```bash
+# Run all tests
+poetry run pytest
+
+# Run unit tests only
+poetry run pytest tests/unit/
+
+# Run a single test file
+poetry run pytest tests/unit/test_foo.py
+
+# Run a single test by name
+poetry run pytest -k "test_name"
+
+# Generate coverage report
+poetry run pytest --cov=src --cov-report=html
+```
+
+### Code Quality
+
+```bash
+# Type checking (strict mode)
+poetry run mypy src/
+
+# Linting
+poetry run ruff check src/
+
+# Formatting
+poetry run ruff format src/
+```
+
+### Pre-commit Hooks
+
+Install pre-commit hooks to run checks automatically before each commit:
+
+```bash
+poetry run pre-commit install
+```
+
+## Environment Variables
+
+Required secrets for deployment:
+
+| Variable | Description |
+|--------------------|--------------------------------|
+| `GEMINI_API_KEY` | Google Gemini API key |
+| `DEEPSEEK_API_KEY` | DeepSeek API key |
+| `GROQ_API_KEY` | Groq API key |
+| `HF_TOKEN` | HuggingFace authentication |
+
+Configuration options:
+
+| Variable | Default | Description |
+|-----------------------|---------|--------------------------------------|
+| `USE_HYBRID` | `true` | Enable BM25 + dense retrieval |
+| `USE_RERANKER` | `false` | Enable cross-encoder reranking |
+| `TOP_K` | `6` | Number of chunks to retrieve |
+| `PROVIDER_TIMEOUT_MS` | `30000` | Timeout before provider fallback |
+
+## Retrieval Configuration
+
+The retrieval system can be tuned via environment variables to balance latency and answer quality.
+
+### Available Settings
+
+| Setting | Default | Range | Description |
+|---------|---------|-------|-------------|
+| `TOP_K` | `6` | 1-20 | Number of chunks to retrieve. Higher values provide more context but increase latency. |
+| `USE_HYBRID` | `true` | boolean | Enable hybrid retrieval combining dense (FAISS) and sparse (BM25) search with RRF fusion. |
+| `USE_RERANKER` | `false` | boolean | Enable cross-encoder reranking for improved relevance. Adds ~200-500ms latency. |
+
+### Retriever Modes
+
+**Hybrid Mode (default):** Combines dense semantic search (FAISS with BGE embeddings) and sparse keyword search (BM25) using Reciprocal Rank Fusion. Best for mixed queries containing both technical terms and natural language.
+
+**Dense-Only Mode:** Uses only FAISS semantic search. Faster than hybrid mode and works well for purely semantic queries where exact keyword matching is less important.
+
+### Performance Tradeoffs
+
+| Setting | Default | Latency Impact | Quality Impact |
+|---------|---------|----------------|----------------|
+| `TOP_K` | 6 | +~5ms per chunk | More context = better answers |
+| `USE_HYBRID` | true | +~20-50ms | Better for mixed queries |
+| `USE_RERANKER` | false | +~200-500ms | Significantly improved relevance |
+
+### Recommended Configurations
+
+**Low-latency production (default):**
+```bash
+USE_HYBRID=true
+USE_RERANKER=false
+TOP_K=6
+```
+
+**High-precision answers:**
+```bash
+USE_HYBRID=true
+USE_RERANKER=true
+TOP_K=10
+```
+
+**Fastest responses:**
+```bash
+USE_HYBRID=false
+USE_RERANKER=false
+TOP_K=3
+```
+
+## HuggingFace Repositories
+
+| Repository | Purpose |
+|----------------------------------|--------------------------------------|
+| `sadickam/pytherm_index` | Chunks, embeddings, FAISS/BM25 indexes |
+| `sadickam/Pytherm_Qlog` | Query/answer logs (no PII) |
+| `sadickam/Pythermalcomfort-Chat` | Deployment Space (Docker SDK) |
+
+## License
+
+MIT License - see [LICENSE](LICENSE) for details.
diff --git a/README.md b/README.md
index d393f566810c3804fe86856b38626e58adcc9153..083c4c813dade91fcbb9ee45ee1363c7f3c2ccdd 100644
--- a/README.md
+++ b/README.md
@@ -1,198 +1,249 @@
-# RAG Chatbot for pythermalcomfort
+---
+title: Pythermalcomfort Chat
+emoji: 🌖
+colorFrom: yellow
+colorTo: red
+sdk: docker
+pinned: false
+license: mit
+---
-
-
+
+# 🌡️ Pythermalcomfort RAG Chatbot
-A Retrieval-Augmented Generation (RAG) chatbot for the pythermalcomfort library documentation. The chatbot uses hybrid retrieval (dense embeddings + BM25) to find relevant documentation chunks and streams responses via LLM providers.
+Welcome to the **Pythermalcomfort Chat** — an AI-powered assistant designed to help you understand and use the [pythermalcomfort](https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort) Python library for thermal comfort calculations.
-## Features
+## What is This?
-- Hybrid retrieval combining dense embeddings (BGE) with BM25 keyword search
-- Multi-provider LLM support with automatic fallback (Gemini -> Groq -> DeepSeek)
-- Server-Sent Events (SSE) streaming for real-time responses
-- Structure-aware PDF chunking with heading inheritance
-- Async query logging to HuggingFace datasets
+This chatbot uses **Retrieval-Augmented Generation (RAG)** to provide accurate, documentation-grounded answers about thermal comfort science and the pythermalcomfort library. Instead of relying solely on general AI knowledge, every response is backed by relevant excerpts from the official documentation, ensuring you get precise and reliable information.
-## Installation
+**Why use this chatbot?**
+- 📚 Get instant answers about thermal comfort standards (ASHRAE 55, ISO 7730, EN 16798)
+- 🔧 Learn how to use pythermalcomfort functions with practical examples
+- 🎯 Understand complex concepts like PMV/PPD, adaptive comfort, and more
+- 📖 Receive responses with source citations so you can dive deeper
-### Prerequisites
+---
-- Python 3.11 or higher
-- [Poetry](https://python-poetry.org/docs/#installation) for dependency management
+
+## 💬 What You Can Ask
-### Environment Setup
+Here are some example questions to get you started:
-1. Clone the repository:
- ```bash
- git clone https://github.com/sadickam/pythermalcomfort-chat.git
- cd pythermalcomfort-chat
- ```
+| Category | Example Questions |
+|----------|-------------------|
+| **Concepts** | "What is PMV and how is it calculated?" |
+| | "What is the difference between PMV and PPD?" |
+| | "How does adaptive thermal comfort work?" |
+| **Standards** | "What are the ASHRAE Standard 55 requirements?" |
+| | "What thermal comfort categories does ISO 7730 define?" |
+| | "What is the EN 16798 standard?" |
+| **Library Usage** | "What inputs does the pmv_ppd function need?" |
+| | "How do I calculate thermal comfort for an office?" |
+| | "How do I use the adaptive comfort model?" |
+| **Parameters** | "What is metabolic rate and how do I estimate it?" |
+| | "How do I measure clothing insulation?" |
+| | "What is operative temperature?" |
-2. Create and activate a virtual environment:
- ```bash
- python -m venv therm_venv
- source therm_venv/bin/activate # Linux/macOS
- # or
- therm_venv\Scripts\activate # Windows
- ```
+---
-3. Install dependencies using one of the install modes below.
+
+## ⚙️ Technical Features
-### Install Modes
+| Feature | Description |
+|---------|-------------|
+| **RAG-Powered Responses** | Every answer includes source citations from pythermalcomfort documentation |
+| **Hybrid Retrieval** | Combines dense embeddings (FAISS) + sparse retrieval (BM25) with Reciprocal Rank Fusion for accurate document search |
+| **Multi-Provider LLM** | Automatic fallback chain: Gemini → DeepSeek → Anthropic → Groq ensures high availability |
+| **Real-Time Streaming** | Responses stream via Server-Sent Events (SSE) for a responsive chat experience |
+| **Query Logging** | Anonymous query logging enables continuous improvement of retrieval quality |
-The project uses Poetry dependency groups to support different deployment scenarios:
+---
-```bash
-# Server only (BM25 retrieval) - lightweight, no GPU required
-poetry install --with serve
+## 🤖 Available LLM Models
-# Server with dense retrieval - requires torch, optional GPU
-poetry install --with serve,dense
+
-# Full build pipeline (local) - for rebuilding embeddings and indexes
-poetry install --with build,dev
-```
+The chatbot leverages multiple LLM providers with intelligent fallback to ensure high availability:
+
+### Google Gemini (Primary Provider)
+
+| Model | Rate Limits (Free Tier) | Description |
+|-------|-------------------------|-------------|
+| `gemini-2.5-flash-lite` | 10 RPM, 250K TPM | Primary model - fastest response times |
+| `gemini-2.5-flash` | 5 RPM, 250K TPM | Secondary - balanced speed and quality |
+| `gemini-3-flash` | 5 RPM, 250K TPM | Tertiary - latest Gemini capabilities |
+| `gemma-3-27b-it` | 30 RPM, 15K TPM | Final fallback - open-weights model |
+
+### Groq (High-Speed Provider)
+
+| Model | Rate Limits (Free Tier) | Description |
+|-------|-------------------------|-------------|
+| `openai/gpt-oss-120b` | 30 RPM, 8K TPM | Primary - large model via Groq |
+| `llama-3.3-70b-versatile` | 30 RPM, 12K TPM | Secondary - Meta's Llama 3.3 |
+
+### DeepSeek (Fallback Provider)
-### Dependency Comparison
-
-| Dependency | Core | Serve | Dense | Build |
-|------------------------|:----:|:-----:|:-----:|:-----:|
-| pydantic | x | | | |
-| pydantic-settings | x | | | |
-| numpy | x | | | |
-| httpx | x | | | |
-| tiktoken | x | | | |
-| rank-bm25 | x | | | |
-| faiss-cpu | x | | | |
-| fastapi | | x | | |
-| uvicorn | | x | | |
-| sse-starlette | | x | | |
-| sentence-transformers | | | x | x |
-| torch | | | x | x |
-| pymupdf4llm | | | | x |
-| pymupdf | | | | x |
-| pyarrow | | | | x |
-| datasets | | | | x |
-
-**Install mode explanations:**
-
-- **Core**: Always installed. Provides base functionality for retrieval and LLM communication.
-- **Serve** (`--with serve`): Adds FastAPI server for hosting the chatbot API. Suitable for CPU-only deployments using precomputed embeddings.
-- **Dense** (`--with dense`): Adds sentence-transformers and PyTorch for runtime embedding generation. Use when you need to embed queries with dense vectors.
-- **Build** (`--with build`): Full offline pipeline for processing PDFs, generating embeddings, and publishing indexes to HuggingFace.
-
-## Quick Start
-
-1. Set up environment variables:
- ```bash
- cp .env.example .env
- # Edit .env with your API keys
- ```
-
-2. Start the server:
- ```bash
- poetry run uvicorn src.rag_chatbot.api.main:app --reload
- ```
-
-3. The API will be available at `http://localhost:8000`
-
-## Project Structure
+| Model | Description |
+|-------|-------------|
+| `deepseek-chat` | Cost-effective alternative with strong reasoning |
+
+### Provider Fallback Chain
```
-src/rag_chatbot/
-├── api/ # FastAPI endpoints and middleware
-├── chunking/ # Structure-aware document chunking
-├── config/ # Settings and configuration
-├── embeddings/ # BGE encoder and storage
-├── extraction/ # PDF to Markdown conversion
-├── llm/ # LLM provider registry (Gemini, Groq, DeepSeek)
-├── qlog/ # Async query logging to HuggingFace
-└── retrieval/ # Hybrid retriever (FAISS + BM25 with RRF fusion)
+Gemini → Groq → DeepSeek → Anthropic
+ ↓ ↓ ↓ ↓
+Primary Fast Budget Premium
```
-## Build Pipeline
+When a provider hits rate limits or encounters errors:
+1. The system automatically tries the next provider in the chain
+2. Rate limit cooldowns are tracked per-model
+3. Responses indicate which provider was used
-For rebuilding embeddings and indexes from source PDFs:
+> **Note**: The fallback chain ensures maximum availability while staying within free tier limits.
-```bash
-# Full rebuild: extract -> chunk -> embed -> index -> publish
-poetry run python scripts/rebuild.py data/raw/
+---
-# Individual steps
-poetry run python scripts/extract.py data/raw/ data/processed/
-poetry run python scripts/chunk.py data/processed/ data/chunks/chunks.jsonl
-poetry run python scripts/embed.py data/chunks/chunks.jsonl data/embeddings/ --publish
-```
+
+## 📖 About pythermalcomfort
-## Development
+**Thermal comfort** refers to the condition of mind that expresses satisfaction with the thermal environment. It's influenced by factors like air temperature, humidity, air velocity, radiant temperature, clothing, and metabolic rate.
-### Running Tests
+The **pythermalcomfort** library is an open-source Python package that implements thermal comfort models and indices according to international standards, including:
-```bash
-# Run all tests
-poetry run pytest
+- 🏛️ **ASHRAE Standard 55** — Thermal Environmental Conditions for Human Occupancy
+- 🌍 **ISO 7730** — Ergonomics of the thermal environment
+- 🇪🇺 **EN 16798** — Energy performance of buildings
+
+### Key Capabilities
+
+- Calculate **PMV (Predicted Mean Vote)** and **PPD (Predicted Percentage Dissatisfied)**
+- Evaluate **adaptive thermal comfort** for naturally ventilated buildings
+- Compute various thermal comfort indices (SET, UTCI, Cooling Effect, etc.)
+- Support for both SI and IP unit systems
+
+### Resources
+
+- 📦 **GitHub Repository**: [CenterForTheBuiltEnvironment/pythermalcomfort](https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort)
+- 📚 **Documentation**: [pythermalcomfort.readthedocs.io](https://pythermalcomfort.readthedocs.io/)
+- 🐍 **PyPI**: [pythermalcomfort on PyPI](https://pypi.org/project/pythermalcomfort/)
-# Run unit tests only
-poetry run pytest tests/unit/
+---
-# Run a single test file
-poetry run pytest tests/unit/test_foo.py
+
+## 🔌 API Endpoints
-# Run a single test by name
-poetry run pytest -k "test_name"
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/query` | POST | Submit a question and receive a streamed response |
+| `/api/providers` | GET | Check availability status of LLM providers |
+| `/health` | GET | Basic health check |
+| `/health/ready` | GET | Readiness probe (checks if indexes are loaded) |
-# Generate coverage report
-poetry run pytest --cov=src --cov-report=html
+### Query Request Format
+
+```json
+{
+ "question": "What is PMV?",
+ "provider": "gemini"
+}
```
-### Code Quality
+The `provider` field is optional. If omitted, the system automatically selects the best available provider.
-```bash
-# Type checking (strict mode)
-poetry run mypy src/
+---
-# Linting
-poetry run ruff check src/
+
+## 🏗️ Architecture
-# Formatting
-poetry run ruff format src/
+This Space uses a multi-container architecture optimized for HuggingFace Spaces:
+
+```
+┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+│ Nginx │────▶│ Next.js │ │ FastAPI │
+│ (Proxy) │────▶│ Frontend │────▶│ Backend │
+└─────────────┘ └─────────────┘ └─────────────┘
+ │
+ ▼
+ ┌─────────────────┐
+ │ FAISS + BM25 │
+ │ Indexes │
+ └─────────────────┘
```
-### Pre-commit Hooks
+- **Nginx** — Reverse proxy handling routing between frontend and backend
+- **Next.js** — React frontend with responsive chat interface
+- **FastAPI** — Python backend with RAG pipeline and LLM orchestration
+
+The backend downloads prebuilt FAISS indexes and BM25 vocabularies from the [`sadickam/pytherm_index`](https://huggingface.co/datasets/sadickam/pytherm_index) HuggingFace dataset at startup, enabling efficient hybrid retrieval without requiring GPU compute.
+
+---
+
+
+## 👩💻 For Developers
+
+Interested in running this locally or contributing? See the **[PROJECT_README.md](PROJECT_README.md)** for detailed development setup instructions, including:
-Install pre-commit hooks to run checks automatically before each commit:
+- Local development without Docker
+- Building the retrieval indexes from scratch
+- Running tests and type checking
+- Contributing guidelines
+
+### Quick Start (Development)
```bash
-poetry run pre-commit install
+# Backend
+poetry install --with serve,dense
+poetry run uvicorn src.rag_chatbot.api.main:app --reload --port 7860
+
+# Frontend (in separate terminal)
+cd frontend
+npm install
+npm run dev
```
-## Environment Variables
+---
+
+
+## 🔐 Environment Variables (Deployment)
+
+The following secrets must be configured in HuggingFace Space settings for deployment:
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `GEMINI_API_KEY` | Yes | Google Gemini API key (primary provider) |
+| `DEEPSEEK_API_KEY` | No | DeepSeek API key (fallback provider) |
+| `ANTHROPIC_API_KEY` | No | Anthropic API key (fallback provider) |
+| `GROQ_API_KEY` | No | Groq API key (fallback provider) |
+| `HF_TOKEN` | Yes | HuggingFace token for accessing datasets and query logging |
+
+**Note:** At least one LLM provider key is required for the chatbot to function.
-Required secrets for deployment:
+---
-| Variable | Description |
-|--------------------|--------------------------------|
-| `GEMINI_API_KEY` | Google Gemini API key |
-| `DEEPSEEK_API_KEY` | DeepSeek API key |
-| `GROQ_API_KEY` | Groq API key |
-| `HF_TOKEN` | HuggingFace authentication |
+
+## 🔗 Related Repositories
-Configuration options:
+| Repository | Description |
+|------------|-------------|
+| [pythermalcomfort](https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort) | The Python library this chatbot documents |
+| [sadickam/pytherm_index](https://huggingface.co/datasets/sadickam/pytherm_index) | Prebuilt retrieval indexes (FAISS + BM25) |
+| [sadickam/Pytherm_Qlog](https://huggingface.co/datasets/sadickam/Pytherm_Qlog) | Anonymous query logs for improvement |
-| Variable | Default | Description |
-|-----------------------|---------|--------------------------------------|
-| `USE_HYBRID` | `true` | Enable BM25 + dense retrieval |
-| `TOP_K` | `6` | Number of chunks to retrieve |
-| `PROVIDER_TIMEOUT_MS` | `30000` | Timeout before provider fallback |
+---
-## HuggingFace Repositories
+
+## 📄 License
-| Repository | Purpose |
-|----------------------------------|--------------------------------------|
-| `sadickam/pytherm_index` | Chunks, embeddings, FAISS/BM25 indexes |
-| `sadickam/Pytherm_Qlog` | Query/answer logs (no PII) |
-| `sadickam/Pythermalcomfort-Chat` | Deployment Space (Docker SDK) |
+This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
-## License
+---
-MIT License - see [LICENSE](LICENSE) for details.
+
+ Made with ❤️ for the thermal comfort research community
+
diff --git a/commands b/commands
index c31bc5717ee68e3a8eac2cb08c553f172467df9f..a917c0f1b1036afb6f9e8da012da081347bfb386 100644
--- a/commands
+++ b/commands
@@ -41,6 +41,10 @@ Verbose mode - Basic rebuild with detailed output showing each file being proces
clear all artifacts, generate new artifacts and publish to HF
+## Commit to HF Space
+- git push space master --force ( first time)
+- git push space master (subsequent commits)
+
## CREATE AASBS2 and SDG Targets Database with Embeddings
@@ -85,9 +89,8 @@ After your solution, rate your confidence (0-1) on:
If any score < 0.9, refine your answer.
[TASK]
- Read the **## Overview** sections of **IMPLEMENTATION_PLAN.md** to understand the system, and context.
-- Review **Step 4.7: Implement Full Rebuild Script** step by step to understand all of its requirements and objectives.
-- Spawn specilaised agents and orchestrate them to succesfully complete all tasks for **Step 4.7: Implement Full Rebuild Script** based on defined details in the **@IMPLEMENTATION_PLAN.md**
-- Ensure normalisation includes correcting jumbled words and sentences, removing extra spaces, and ensuring proper capitalization.
+- Review **Step 9.5: Add Dataset Freshness Check on Startup** step by step to understand all of its requirements and objectives.
+- Spawn specilaised agents and orchestrate them to succesfully complete all tasks for **Step 9.5: Add Dataset Freshness Check on Startup** based on defined details in the **@IMPLEMENTATION_PLAN.md**
- Spawn at most 2 specialised agents at a time
- Ensure clean hands off between agents to avoid file write conflicts occurring between agents and potential data loss
- It is critical to identify and fix potentially missing items that can affect production-readiness.
diff --git a/docs/HF_SPACE_CONFIG.md b/docs/HF_SPACE_CONFIG.md
new file mode 100644
index 0000000000000000000000000000000000000000..d0ea0055e1491a71dd2be62988c4725e0ccf79bf
--- /dev/null
+++ b/docs/HF_SPACE_CONFIG.md
@@ -0,0 +1,427 @@
+# HuggingFace Space Configuration Guide
+
+
+
+## Overview
+
+This guide explains how to configure the HuggingFace Space at **[sadickam/Pythermalcomfort-Chat](https://huggingface.co/spaces/sadickam/Pythermalcomfort-Chat)** for deployment.
+
+The Space uses the **Docker SDK**, which means:
+- HuggingFace builds a Docker image from the repository's `Dockerfile`
+- The container runs on HuggingFace's infrastructure
+- Environment variables and secrets are injected at runtime
+- The application serves both the Next.js frontend and FastAPI backend
+
+---
+
+## README File Structure
+
+
+
+The HuggingFace Space requires a `README.md` file with specific YAML frontmatter for configuration. This repository is structured so that `README.md` serves as the Space configuration file directly, eliminating the need for file renaming during deployment.
+
+### File Structure
+
+| File | Purpose |
+|------|---------|
+| `README.md` | HuggingFace Space configuration with YAML frontmatter, user-facing documentation |
+| `PROJECT_README.md` | Developer documentation, installation instructions, contribution guidelines |
+
+### YAML Frontmatter Requirements
+
+The `README.md` starts with this required frontmatter:
+
+```yaml
+---
+title: Pythermalcomfort Chat
+emoji: 🌖
+colorFrom: yellow
+colorTo: red
+sdk: docker
+pinned: false
+license: mit
+---
+```
+
+This tells HuggingFace how to build and display the Space.
+
+---
+
+## Required Secrets Configuration
+
+
+
+### Secret Reference Table
+
+| Secret Name | Required | Description |
+|-------------|----------|-------------|
+| `GEMINI_API_KEY` | **Required** | Google Gemini API key - Primary LLM provider for generating responses |
+| `HF_TOKEN` | **Required** | HuggingFace token for accessing the index dataset and query logging |
+| `DEEPSEEK_API_KEY` | Optional | DeepSeek API key - First fallback provider if Gemini fails |
+| `ANTHROPIC_API_KEY` | Optional | Anthropic Claude API key - Second fallback provider |
+| `GROQ_API_KEY` | Optional | Groq API key - Third fallback provider for fast inference |
+
+### Step-by-Step Configuration
+
+1. **Navigate to the Space Settings**
+ ```
+ https://huggingface.co/spaces/sadickam/Pythermalcomfort-Chat/settings
+ ```
+
+2. **Scroll to the "Repository secrets" section**
+ - This section is located under "Variables and secrets" in the Settings page
+
+3. **Add each secret**
+ - Click "New secret"
+ - Enter the secret name exactly as shown (e.g., `GEMINI_API_KEY`)
+ - Paste the API key value
+ - Click "Add"
+ - Repeat for each secret
+
+4. **Required secrets setup**
+
+ **GEMINI_API_KEY** (Required):
+ ```
+ # Obtain from: https://makersuite.google.com/app/apikey
+ # This is the primary LLM provider - the chatbot will not function without it
+ ```
+
+ **HF_TOKEN** (Required):
+ ```
+ # Obtain from: https://huggingface.co/settings/tokens
+ # Required permissions:
+ # - Read access to sadickam/pytherm_index (prebuilt indexes)
+ # - Write access to sadickam/Pytherm_Qlog (query logging)
+ ```
+
+5. **Optional fallback provider secrets**
+
+
+
+ **DEEPSEEK_API_KEY** (Optional):
+ ```
+ # Obtain from: https://platform.deepseek.com/
+ # First fallback if Gemini is unavailable
+ ```
+
+ **ANTHROPIC_API_KEY** (Optional):
+ ```
+ # Obtain from: https://console.anthropic.com/
+ # Second fallback provider
+ ```
+
+ **GROQ_API_KEY** (Optional):
+ ```
+ # Obtain from: https://console.groq.com/
+ # Third fallback - offers fast inference times
+ ```
+
+6. **Restart the Space**
+ - After adding all secrets, click "Restart" in the Space header
+ - The Space will rebuild and inject the new secrets
+
+---
+
+## Hardware Settings
+
+
+
+### Recommended Configuration
+
+| Setting | Value | Reason |
+|---------|-------|--------|
+| **Hardware** | CPU Basic (Free) | No GPU required for inference |
+| **Sleep timeout** | Default (48 hours) | Adjust based on usage patterns |
+
+### Why CPU is Sufficient
+
+1. **Prebuilt Indexes**: The FAISS and BM25 indexes are built offline with GPU acceleration and published to `sadickam/pytherm_index`. At startup, the Space downloads these prebuilt artifacts.
+
+2. **No Local Embedding**: Query embedding uses the same BGE model but runs efficiently on CPU for single queries.
+
+3. **External LLM Providers**: Response generation is handled by external API providers (Gemini, DeepSeek, etc.), not local models.
+
+4. **Cost Optimization**: The free CPU tier is sufficient for the expected load and keeps operational costs at zero.
+
+### Configuring Hardware
+
+1. Navigate to: `https://huggingface.co/spaces/sadickam/Pythermalcomfort-Chat/settings`
+2. Find the "Space hardware" section
+3. Select "CPU basic" from the dropdown
+4. Click "Save" (the Space will restart automatically)
+
+---
+
+## Deployment Verification Checklist
+
+
+
+### Pre-flight Checks
+
+- [ ] All required secrets are configured (`GEMINI_API_KEY`, `HF_TOKEN`)
+- [ ] Hardware is set to "CPU Basic"
+- [ ] Space is set to "Public" or "Private" as needed
+
+### Build Verification
+
+- [ ] **Space builds successfully**
+ - Check the "Logs" tab for build output
+ - Build should complete without errors in 5-10 minutes
+ - Look for "Application startup complete" in logs
+
+### Health Endpoint Checks
+
+
+
+1. **Basic Health Check**
+ ```bash
+ curl https://sadickam-pythermalcomfort-chat.hf.space/health
+ ```
+ Expected response:
+ ```json
+ {"status": "healthy"}
+ ```
+ - [ ] Returns HTTP 200
+
+2. **Readiness Check**
+ ```bash
+ curl https://sadickam-pythermalcomfort-chat.hf.space/health/ready
+ ```
+ Expected response:
+ ```json
+ {
+ "status": "ready",
+ "indexes_loaded": true,
+ "chunks_count": ,
+ "faiss_index_size":
+ }
+ ```
+ - [ ] Returns HTTP 200
+ - [ ] `indexes_loaded` is `true`
+ - [ ] `chunks_count` is greater than 0
+
+3. **Provider Availability**
+ ```bash
+ curl https://sadickam-pythermalcomfort-chat.hf.space/api/providers
+ ```
+ Expected response:
+ ```json
+ {
+ "available": ["gemini", ...],
+ "primary": "gemini"
+ }
+ ```
+ - [ ] Returns HTTP 200
+ - [ ] At least one provider in `available` list
+ - [ ] `primary` is set (typically "gemini")
+
+### Functional Test
+
+- [ ] **Test a sample query through the UI**
+ 1. Open `https://sadickam-pythermalcomfort-chat.hf.space`
+ 2. Wait for the interface to load
+ 3. Enter a test question: "What is PMV?"
+ 4. Verify:
+ - Response streams in real-time
+ - Response includes relevant information about PMV (Predicted Mean Vote)
+ - Source citations are included
+
+---
+
+## Troubleshooting
+
+
+
+### Space Stuck in "Building"
+
+**Symptoms:**
+- Build process runs for more than 15 minutes
+- Build log shows no progress or loops
+
+**Solutions:**
+1. **Check Dockerfile syntax**
+ ```bash
+ # Validate locally before pushing
+ docker build -t test-build .
+ ```
+
+2. **Review build logs**
+ - Click "Logs" tab in the Space
+ - Look for error messages or failed commands
+ - Common issues: missing files, dependency conflicts
+
+3. **Clear build cache**
+ - Go to Settings > "Factory reboot"
+ - This clears cached layers and rebuilds from scratch
+
+### Health Check Failing
+
+**Symptoms:**
+- `/health` returns 500 or connection refused
+- Space shows as "Running" but endpoints don't respond
+
+**Solutions:**
+1. **Verify secrets are configured**
+ - Go to Settings > "Repository secrets"
+ - Confirm `GEMINI_API_KEY` and `HF_TOKEN` are present
+ - Note: You cannot see secret values, only that they exist
+
+2. **Check application logs**
+ ```
+ # Look for startup errors in the Logs tab
+ # Common messages:
+ # - "Missing required environment variable"
+ # - "Failed to initialize provider"
+ ```
+
+3. **Restart the Space**
+ - Click the three-dot menu > "Restart"
+ - Wait 2-3 minutes for full startup
+
+### No Providers Available
+
+**Symptoms:**
+- `/api/providers` returns `{"available": [], "primary": null}`
+- Chat interface shows "No providers available" error
+
+**Solutions:**
+1. **Verify API keys are correct**
+ - Regenerate the API key from the provider's console
+ - Update the secret in HuggingFace Space settings
+ - Restart the Space
+
+2. **Check provider status**
+ - Verify the provider's API is operational
+ - Check for rate limiting or account issues
+
+3. **Review provider logs**
+ ```
+ # Look for these patterns in logs:
+ # - "API key invalid"
+ # - "Rate limit exceeded"
+ # - "Provider initialization failed"
+ ```
+
+### Index Loading Failures
+
+**Symptoms:**
+- `/health/ready` returns `{"indexes_loaded": false}`
+- Logs show "Failed to download artifacts"
+
+**Solutions:**
+1. **Verify HF_TOKEN permissions**
+ - Go to https://huggingface.co/settings/tokens
+ - Ensure the token has "Read" access to `sadickam/pytherm_index`
+ - If using a fine-grained token, add explicit repo access
+
+2. **Check dataset availability**
+ - Visit https://huggingface.co/datasets/sadickam/pytherm_index
+ - Verify the dataset exists and is accessible
+ - Check if the dataset is private and token has access
+
+3. **Manual verification**
+ ```bash
+ # Test token access locally
+ curl -H "Authorization: Bearer $HF_TOKEN" \
+ https://huggingface.co/api/datasets/sadickam/pytherm_index
+ ```
+
+4. **Check disk space**
+ - The index files require ~500MB of storage
+ - HuggingFace Spaces have limited ephemeral storage
+ - Consider reducing index size if this is an issue
+
+### Slow Response Times
+
+**Symptoms:**
+- Queries take more than 30 seconds
+- Responses time out frequently
+
+**Solutions:**
+1. **Check provider latency**
+ - The primary provider (Gemini) may be experiencing high load
+ - Fallback providers will be tried automatically
+
+2. **Verify hybrid retrieval settings**
+ ```
+ # In environment or settings:
+ USE_HYBRID=true # Enable both FAISS and BM25
+ TOP_K=6 # Reduce if responses are slow
+ ```
+
+3. **Monitor Space resources**
+ - Check the "Metrics" tab for CPU/memory usage
+ - Consider upgrading hardware if consistently maxed out
+
+---
+
+## Environment Variables Reference
+
+
+
+### Secrets (Configure in Space Settings)
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `GEMINI_API_KEY` | Yes | Google Gemini API key |
+| `HF_TOKEN` | Yes | HuggingFace access token |
+| `DEEPSEEK_API_KEY` | No | DeepSeek API key |
+| `ANTHROPIC_API_KEY` | No | Anthropic API key |
+| `GROQ_API_KEY` | No | Groq API key |
+
+### Configuration (Can be set in Dockerfile)
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `USE_HYBRID` | `true` | Enable hybrid retrieval (FAISS + BM25) |
+| `TOP_K` | `6` | Number of chunks to retrieve |
+| `PROVIDER_TIMEOUT_MS` | `30000` | Timeout before trying fallback provider |
+| `LOG_LEVEL` | `INFO` | Application log level |
+
+---
+
+## Additional Resources
+
+- [HuggingFace Spaces Documentation](https://huggingface.co/docs/hub/spaces)
+- [Docker SDK Reference](https://huggingface.co/docs/hub/spaces-sdks-docker)
+- [Space Secrets Documentation](https://huggingface.co/docs/hub/spaces-overview#managing-secrets)
+- [pythermalcomfort Library](https://pythermalcomfort.readthedocs.io/)
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..f80eaa8e3510297d8873564f517803a62f668705
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,221 @@
+# =============================================================================
+# Docker Ignore File for Next.js Frontend
+# =============================================================================
+# This file specifies patterns for files and directories that should be
+# excluded from the Docker build context. Proper exclusions:
+# - Reduce build context size (faster docker build)
+# - Prevent unnecessary cache invalidation
+# - Avoid including sensitive or development-only files
+# - Keep the final image smaller and more secure
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Dependencies
+# -----------------------------------------------------------------------------
+# Exclude node_modules as dependencies are installed fresh during build.
+# This ensures consistent, reproducible builds and prevents platform-specific
+# native modules from causing issues (e.g., macOS binaries on Linux).
+node_modules/
+.pnp/
+.pnp.js
+
+# -----------------------------------------------------------------------------
+# Build Outputs
+# -----------------------------------------------------------------------------
+# Exclude local build artifacts. The Docker build process will generate
+# fresh build outputs. Including these would:
+# - Bloat the build context unnecessarily
+# - Potentially cause cache conflicts
+# - Include development build artifacts in production
+.next/
+out/
+build/
+dist/
+
+# -----------------------------------------------------------------------------
+# Version Control
+# -----------------------------------------------------------------------------
+# Git history and metadata are not needed in the container.
+# Excluding .git significantly reduces build context size.
+.git/
+.gitignore
+.gitattributes
+
+# -----------------------------------------------------------------------------
+# Test Files and Coverage
+# -----------------------------------------------------------------------------
+# Testing infrastructure is not needed in production images.
+# Exclude test files, coverage reports, and testing configurations.
+coverage/
+.nyc_output/
+*.test.ts
+*.test.tsx
+*.test.js
+*.test.jsx
+*.spec.ts
+*.spec.tsx
+*.spec.js
+*.spec.jsx
+__tests__/
+__mocks__/
+jest.config.*
+vitest.config.*
+playwright.config.*
+cypress/
+cypress.config.*
+
+# -----------------------------------------------------------------------------
+# Documentation
+# -----------------------------------------------------------------------------
+# Documentation files are not needed for runtime.
+# Keep the image focused on application code only.
+*.md
+!README.md
+docs/
+CHANGELOG*
+LICENSE*
+CONTRIBUTING*
+
+# -----------------------------------------------------------------------------
+# IDE and Editor Configurations
+# -----------------------------------------------------------------------------
+# Editor-specific files and directories should not be in the image.
+# These are developer-specific and vary between team members.
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.project
+.classpath
+.settings/
+*.sublime-*
+
+# -----------------------------------------------------------------------------
+# OS-Generated Files
+# -----------------------------------------------------------------------------
+# Operating system metadata files are never needed in containers.
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+desktop.ini
+
+# -----------------------------------------------------------------------------
+# Debug and Log Files
+# -----------------------------------------------------------------------------
+# Debug logs from package managers and build tools.
+# These can contain sensitive information and are not needed in production.
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# -----------------------------------------------------------------------------
+# Environment Files
+# -----------------------------------------------------------------------------
+# Local environment files often contain secrets.
+# Production secrets should be injected via Docker secrets or env vars.
+# IMPORTANT: Never include .env files with real credentials in images.
+.env
+.env.local
+.env.development
+.env.development.local
+.env.test
+.env.test.local
+.env.production.local
+
+# Note: .env.production is intentionally NOT excluded as it may contain
+# non-sensitive build-time configuration. Review before building.
+
+# -----------------------------------------------------------------------------
+# TypeScript Build Info
+# -----------------------------------------------------------------------------
+# TypeScript incremental compilation cache.
+# Not needed in the container; TypeScript compiles fresh during build.
+*.tsbuildinfo
+tsconfig.tsbuildinfo
+
+# -----------------------------------------------------------------------------
+# Package Manager Lock Files (Alternative)
+# -----------------------------------------------------------------------------
+# If using npm, exclude yarn/pnpm lock files and vice versa.
+# Uncomment the appropriate lines based on your package manager.
+# yarn.lock
+# pnpm-lock.yaml
+# package-lock.json
+
+# -----------------------------------------------------------------------------
+# Storybook
+# -----------------------------------------------------------------------------
+# Storybook is a development tool for UI component documentation.
+# Not needed in production runtime.
+.storybook/
+storybook-static/
+
+# -----------------------------------------------------------------------------
+# Docker Files
+# -----------------------------------------------------------------------------
+# Dockerfiles themselves don't need to be in the build context
+# (Docker reads them separately). Including them can leak build strategy info.
+Dockerfile*
+docker-compose*
+.dockerignore
+
+# -----------------------------------------------------------------------------
+# CI/CD Configuration
+# -----------------------------------------------------------------------------
+# Continuous integration configs are not needed in the runtime image.
+.github/
+.gitlab-ci.yml
+.travis.yml
+.circleci/
+azure-pipelines.yml
+Jenkinsfile
+.buildkite/
+
+# -----------------------------------------------------------------------------
+# Linting and Formatting Configs
+# -----------------------------------------------------------------------------
+# These are development tools; linting happens before build, not at runtime.
+.eslintrc*
+.eslintignore
+eslint.config.*
+.prettierrc*
+.prettierignore
+prettier.config.*
+.stylelintrc*
+.editorconfig
+
+# -----------------------------------------------------------------------------
+# Husky and Git Hooks
+# -----------------------------------------------------------------------------
+# Git hooks are for development workflow, not needed in containers.
+.husky/
+.git-hooks/
+
+# -----------------------------------------------------------------------------
+# Temporary Files
+# -----------------------------------------------------------------------------
+# Temporary files from various tools and processes.
+tmp/
+temp/
+*.tmp
+*.temp
+*.bak
+*.backup
+
+# -----------------------------------------------------------------------------
+# Miscellaneous
+# -----------------------------------------------------------------------------
+# Other files that don't belong in production images.
+Makefile
+*.log
+*.pid
+*.seed
+*.pid.lock
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/frontend/.gitkeep b/frontend/.gitkeep
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..29e2bcd32c8243051609b3231fa9d24ae0e35578
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,21 @@
+# Dependencies
+node_modules
+
+# Build output
+.next
+out
+
+# Coverage
+coverage
+
+# Cache
+.cache
+
+# Lock files
+package-lock.json
+yarn.lock
+pnpm-lock.yaml
+
+# Generated files
+*.min.js
+*.min.css
diff --git a/frontend/README.md b/frontend/README.md
index 38e65147ff9b7e3ddacff533a8da5ff6c6716fa8..e215bc4ccf138bbc38ad58ad57e92135484b3c0f 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1 +1,36 @@
-Next.js frontend - to be implemented in Phase 8
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..99e076eac5325138d9656538611b826806e78f50
--- /dev/null
+++ b/frontend/eslint.config.mjs
@@ -0,0 +1,120 @@
+/**
+ * ESLint Configuration for Next.js Frontend
+ *
+ * This configuration enforces strict TypeScript and React rules for
+ * production-quality code in the RAG Chatbot frontend.
+ *
+ * @see https://eslint.org/docs/latest/use/configure/configuration-files
+ */
+
+import { defineConfig, globalIgnores } from 'eslint/config';
+import nextVitals from 'eslint-config-next/core-web-vitals';
+import nextTs from 'eslint-config-next/typescript';
+import eslintConfigPrettier from 'eslint-config-prettier';
+
+const eslintConfig = defineConfig([
+ // Extend Next.js recommended configs
+ ...nextVitals,
+ ...nextTs,
+
+ // Prettier config to disable formatting rules that conflict
+ eslintConfigPrettier,
+
+ // Global configuration with strict rules for source files
+ {
+ files: ['src/**/*.{ts,tsx}'],
+ rules: {
+ // =============================================
+ // TypeScript Strict Rules
+ // =============================================
+
+ // Enforce explicit return types on functions
+ '@typescript-eslint/explicit-function-return-type': [
+ 'warn',
+ {
+ allowExpressions: true,
+ allowTypedFunctionExpressions: true,
+ },
+ ],
+
+ // Require explicit accessibility modifiers
+ '@typescript-eslint/explicit-member-accessibility': [
+ 'error',
+ {
+ accessibility: 'explicit',
+ overrides: {
+ constructors: 'no-public',
+ },
+ },
+ ],
+
+ // Disallow 'any' type usage
+ '@typescript-eslint/no-explicit-any': 'error',
+
+ // Require consistent type assertions
+ '@typescript-eslint/consistent-type-assertions': [
+ 'error',
+ {
+ assertionStyle: 'as',
+ objectLiteralTypeAssertions: 'never',
+ },
+ ],
+
+ // No unused variables (with underscore exception)
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ },
+ ],
+
+ // =============================================
+ // React & Next.js Rules
+ // =============================================
+
+ // Enforce hooks rules strictly
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+
+ // Prevent missing key props in iterators
+ 'react/jsx-key': ['error', { checkFragmentShorthand: true }],
+
+ // No array index as key
+ 'react/no-array-index-key': 'warn',
+
+ // =============================================
+ // General Best Practices
+ // =============================================
+
+ // No console statements in production
+ 'no-console': ['warn', { allow: ['warn', 'error'] }],
+
+ // Enforce strict equality
+ eqeqeq: ['error', 'always'],
+
+ // No debugger statements
+ 'no-debugger': 'error',
+
+ // Prefer const over let when possible
+ 'prefer-const': 'error',
+
+ // No var declarations
+ 'no-var': 'error',
+ },
+ },
+
+ // Override default ignores of eslint-config-next
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ '.next/**',
+ 'out/**',
+ 'build/**',
+ 'next-env.d.ts',
+ // Additional ignores
+ 'node_modules/**',
+ 'coverage/**',
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..27846f455b0254279cfb7bc8074655ebecb15847
--- /dev/null
+++ b/frontend/next.config.ts
@@ -0,0 +1,263 @@
+import type { NextConfig } from "next";
+
+/**
+ * Next.js Configuration for Production Docker Deployment
+ *
+ * This configuration is optimized for containerized deployments on platforms
+ * like HuggingFace Spaces, Vercel, or any Docker-based hosting.
+ *
+ * Key features:
+ * - Standalone output for minimal Docker image size
+ * - Security hardening (no x-powered-by header)
+ * - Optimized image handling with modern formats
+ * - Environment variable support for dynamic API configuration
+ */
+const nextConfig: NextConfig = {
+ /**
+ * Output Mode: Standalone
+ *
+ * Generates a standalone folder with only the necessary files for production.
+ * This dramatically reduces Docker image size by:
+ * - Including only required node_modules (not devDependencies)
+ * - Creating a minimal server.js that doesn't require the full Next.js installation
+ * - Copying only the files needed for production
+ *
+ * The standalone output is located at `.next/standalone/` after build.
+ * To run: `node .next/standalone/server.js`
+ *
+ * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/output
+ */
+ output: "standalone",
+
+ /**
+ * React Strict Mode
+ *
+ * Enables React's Strict Mode for the entire application.
+ * Benefits:
+ * - Identifies components with unsafe lifecycles
+ * - Warns about deprecated API usage
+ * - Detects unexpected side effects by double-invoking certain functions
+ * - Ensures reusable state (important for React 18+ concurrent features)
+ *
+ * Note: This only affects development mode; production builds are not impacted.
+ *
+ * @see https://react.dev/reference/react/StrictMode
+ */
+ reactStrictMode: true,
+
+ /**
+ * Security: Disable x-powered-by Header
+ *
+ * Removes the "X-Powered-By: Next.js" HTTP response header.
+ * Security benefits:
+ * - Reduces information disclosure about the tech stack
+ * - Makes it slightly harder for attackers to target Next.js-specific vulnerabilities
+ * - Follows security best practice of minimizing fingerprinting
+ *
+ * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/poweredByHeader
+ */
+ poweredByHeader: false,
+
+ /**
+ * Image Optimization Configuration
+ *
+ * Configures Next.js Image component optimization settings.
+ * This affects how images are processed, cached, and served.
+ */
+ images: {
+ /**
+ * Image Formats
+ *
+ * Specifies the output formats for optimized images, in order of preference.
+ * - AVIF: Best compression, ~50% smaller than JPEG, but slower to encode
+ * - WebP: Good compression, ~30% smaller than JPEG, widely supported
+ *
+ * The browser's Accept header determines which format is served.
+ * Modern browsers get AVIF/WebP; older browsers fall back to original format.
+ *
+ * Note: AVIF encoding is CPU-intensive; consider removing if build times are critical.
+ */
+ formats: ["image/avif", "image/webp"],
+
+ /**
+ * Remote Patterns
+ *
+ * Defines allowed external image sources for security.
+ * Only images from these patterns can be optimized by Next.js.
+ * Add patterns here if you need to serve images from external CDNs or APIs.
+ *
+ * Example:
+ * remotePatterns: [
+ * { protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**' }
+ * ]
+ */
+ remotePatterns: [],
+
+ /**
+ * Unoptimized Mode
+ *
+ * When true, disables image optimization entirely.
+ * Set to true if:
+ * - Deploying to a platform without image optimization support
+ * - Using an external image CDN (Cloudinary, imgix, etc.)
+ * - Debugging image-related issues
+ *
+ * Default: false (optimization enabled)
+ */
+ unoptimized: false,
+ },
+
+ /**
+ * Environment Variables Configuration
+ *
+ * Exposes environment variables to the browser (client-side).
+ * Variables listed here are inlined at build time.
+ *
+ * IMPORTANT: Only expose non-sensitive values here.
+ * Never expose API keys, secrets, or credentials.
+ *
+ * For runtime configuration (not inlined at build), use:
+ * - NEXT_PUBLIC_* prefix for client-side runtime vars
+ * - Server-side API routes for sensitive operations
+ */
+ env: {
+ /**
+ * API Base URL
+ *
+ * The base URL for the backend API server.
+ * - Development: Usually http://localhost:8000
+ * - Production: The deployed backend URL or relative path
+ *
+ * Falls back to empty string for same-origin API calls (relative URLs).
+ * This allows the frontend to work with both:
+ * - Separate backend deployment (absolute URL)
+ * - Same-origin deployment (relative URL like /api)
+ */
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "",
+ },
+
+ /**
+ * Experimental Features
+ *
+ * Enable experimental Next.js features.
+ * Use with caution in production as these may change between versions.
+ */
+ experimental: {
+ /**
+ * Optimize Package Imports
+ *
+ * Enables automatic tree-shaking for specific packages.
+ * Reduces bundle size by only importing used exports.
+ * Particularly useful for large UI libraries.
+ *
+ * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports
+ */
+ optimizePackageImports: ["lucide-react"],
+ },
+
+ /**
+ * Webpack Configuration Override
+ *
+ * Custom webpack configuration for advanced build customization.
+ * Use sparingly as it can complicate upgrades and debugging.
+ *
+ * @param config - The existing webpack configuration
+ * @param context - Build context including isServer, dev, etc.
+ * @returns Modified webpack configuration
+ */
+ webpack: (config, { isServer }) => {
+ /**
+ * Suppress Critical Dependency Warnings
+ *
+ * Some packages (especially those using dynamic requires) trigger
+ * webpack warnings that are not actionable. This filter suppresses
+ * known false-positive warnings to keep build output clean.
+ */
+ if (!isServer) {
+ // Client-side specific webpack modifications can go here
+ // Example: config.resolve.fallback = { fs: false, path: false };
+ }
+
+ return config;
+ },
+
+ /**
+ * HTTP Headers Configuration
+ *
+ * Custom HTTP headers for all routes.
+ * Used for security headers, caching policies, and CORS.
+ *
+ * @returns Array of header configurations
+ */
+ async headers() {
+ return [
+ {
+ // Apply to all routes
+ source: "/:path*",
+ headers: [
+ /**
+ * Content Security Policy
+ *
+ * Restricts resource loading to prevent XSS attacks.
+ * Customize based on your application's needs.
+ * Note: This is a basic policy; adjust for production.
+ */
+ // Uncomment and customize for production:
+ // {
+ // key: 'Content-Security-Policy',
+ // value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
+ // },
+
+ /**
+ * X-Content-Type-Options
+ *
+ * Prevents MIME type sniffing attacks.
+ * Forces browser to respect declared Content-Type.
+ */
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+
+ /**
+ * X-Frame-Options
+ *
+ * Prevents clickjacking by controlling iframe embedding.
+ * DENY: Cannot be embedded in any iframe
+ * SAMEORIGIN: Can only be embedded by same-origin pages
+ */
+ {
+ key: "X-Frame-Options",
+ value: "SAMEORIGIN",
+ },
+
+ /**
+ * X-XSS-Protection
+ *
+ * Enables browser's built-in XSS filtering.
+ * Note: Modern browsers rely more on CSP, but this provides
+ * additional protection for older browsers.
+ */
+ {
+ key: "X-XSS-Protection",
+ value: "1; mode=block",
+ },
+
+ /**
+ * Referrer-Policy
+ *
+ * Controls how much referrer information is sent with requests.
+ * strict-origin-when-cross-origin: Full path for same-origin,
+ * only origin for cross-origin, nothing for downgrade to HTTP.
+ */
+ {
+ key: "Referrer-Policy",
+ value: "strict-origin-when-cross-origin",
+ },
+ ],
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..07020b4afde0570ac565c0c75175eb1b8c376485
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,11333 @@
+{
+ "name": "frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tailwindcss/typography": "^0.5.19",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.26.2",
+ "lucide-react": "^0.562.0",
+ "next": "16.1.2",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "remark-gfm": "^4.0.1",
+ "tailwind-merge": "^3.4.0"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.3",
+ "@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@typescript-eslint/eslint-plugin": "^8.53.0",
+ "@typescript-eslint/parser": "^8.53.0",
+ "@vitejs/plugin-react": "^4.5.2",
+ "@vitest/coverage-v8": "^3.2.4",
+ "eslint": "^9",
+ "eslint-config-next": "16.1.2",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.5",
+ "jsdom": "^26.1.0",
+ "prettier": "^3.8.0",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "tailwindcss": "^4",
+ "typescript": "^5",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+ "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
+ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+ "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+ "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
+ "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+ "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
+ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
+ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
+ "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.10.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.2.tgz",
+ "integrity": "sha512-r6TpLovDTvWtzw11UubUQxEK6IduT8rSAHbGX68yeFpA/1Oq9R4ovi5nqMUMgPN0jr2SpfeyFRbTZg3Inuuv3g==",
+ "license": "MIT"
+ },
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.2.tgz",
+ "integrity": "sha512-jjO5BKDxZEXt2VCAnAG/ldULnpxeXspjCo9AZErV3Lm5HmNj8r2rS+eUMIAAj6mXPAOiPqAMgVPGnkyhPyDx4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-glob": "3.3.1"
+ }
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.2.tgz",
+ "integrity": "sha512-0N2baysDpTXASTVxTV+DkBnD97bo9PatUj8sHlKA+oR9CyvReaPQchQyhCbH0Jm0mC/Oka5F52intN+lNOhSlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.2.tgz",
+ "integrity": "sha512-Q0wnSK0lmeC9ps+/w/bDsMSF3iWS45WEwF1bg8dvMH3CmKB2BV4346tVrjWxAkrZq20Ro6Of3R19IgrEJkXKyw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.2.tgz",
+ "integrity": "sha512-4twW+h7ZatGKWq+2pUQ9SDiin6kfZE/mY+D8jOhSZ0NDzKhQfAPReXqwTDWVrNjvLzHzOcDL5kYjADHfXL/b/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.2.tgz",
+ "integrity": "sha512-Sn6LxPIZcADe5AnqqMCfwBv6vRtDikhtrjwhu+19WM6jHZe31JDRcGuPZAlJrDk6aEbNBPUUAKmySJELkBOesg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.2.tgz",
+ "integrity": "sha512-nwzesEQBfQIOOnQ7JArzB08w9qwvBQ7nC1i8gb0tiEFH94apzQM3IRpY19MlE8RBHxc9ArG26t1DEg2aaLaqVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.2.tgz",
+ "integrity": "sha512-s60bLf16BDoICQHeKEm0lDgUNMsL1UpQCkRNZk08ZNnRpK0QUV+6TvVHuBcIA7oItzU0m7kVmXe8QjXngYxJVA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.2.tgz",
+ "integrity": "sha512-Sq8k4SZd8Y8EokKdz304TvMO9HoiwGzo0CTacaiN1bBtbJSQ1BIwKzNFeFdxOe93SHn1YGnKXG6Mq3N+tVooyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.2.tgz",
+ "integrity": "sha512-KQDBwspSaNX5/wwt6p7ed5oINJWIxcgpuqJdDNubAyq7dD+ZM76NuEjg8yUxNOl5R4NNgbMfqE/RyNrsbYmOKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nolyfill/is-core-module": {
+ "version": "1.0.39",
+ "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
+ "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.4.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@pkgr/core": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/pkgr"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
+ "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
+ "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
+ "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
+ "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
+ "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
+ "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
+ "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
+ "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
+ "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
+ "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
+ "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
+ "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
+ "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
+ "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
+ "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
+ "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
+ "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
+ "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
+ "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
+ "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
+ "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
+ "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
+ "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "postcss": "^8.4.41",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.19",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
+ "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
+ "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.30",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
+ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
+ "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.53.0",
+ "@typescript-eslint/type-utils": "8.53.0",
+ "@typescript-eslint/utils": "8.53.0",
+ "@typescript-eslint/visitor-keys": "8.53.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.53.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz",
+ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.53.0",
+ "@typescript-eslint/types": "8.53.0",
+ "@typescript-eslint/typescript-estree": "8.53.0",
+ "@typescript-eslint/visitor-keys": "8.53.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
+ "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.53.0",
+ "@typescript-eslint/types": "^8.53.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
+ "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.53.0",
+ "@typescript-eslint/visitor-keys": "8.53.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
+ "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz",
+ "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.53.0",
+ "@typescript-eslint/typescript-estree": "8.53.0",
+ "@typescript-eslint/utils": "8.53.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
+ "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
+ "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.53.0",
+ "@typescript-eslint/tsconfig-utils": "8.53.0",
+ "@typescript-eslint/types": "8.53.0",
+ "@typescript-eslint/visitor-keys": "8.53.0",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
+ "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.53.0",
+ "@typescript-eslint/types": "8.53.0",
+ "@typescript-eslint/typescript-estree": "8.53.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
+ "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.53.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "license": "ISC"
+ },
+ "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+ "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+ "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+ "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+ "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+ "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+ "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+ "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+ "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+ "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+ "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+ "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+ "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
+ "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+ "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+ "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+ "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
+ "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.4",
+ "vitest": "3.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
+ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+ "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
+ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-types-flow": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
+ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+ "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.11.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
+ "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
+ "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001764",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
+ "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/damerau-levenshtein": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
+ "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.267",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.1",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
+ "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz",
+ "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.1",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.1.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.3.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.5",
+ "safe-array-concat": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
+ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.39.2",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.2.tgz",
+ "integrity": "sha512-y97rpFfUsaXdXlQc2FMl/yqRc5yfVVKtKRcv+7LeyBrKh83INFegJuZBE28dc9Chp4iKXwmjaW4sHHx/mgyDyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@next/eslint-plugin-next": "16.1.2",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-import-resolver-typescript": "^3.5.2",
+ "eslint-plugin-import": "^2.32.0",
+ "eslint-plugin-jsx-a11y": "^6.10.0",
+ "eslint-plugin-react": "^7.37.0",
+ "eslint-plugin-react-hooks": "^7.0.0",
+ "globals": "16.4.0",
+ "typescript-eslint": "^8.46.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=9.0.0",
+ "typescript": ">=3.3.1"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next/node_modules/globals": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
+ "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
+ "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@nolyfill/is-core-module": "1.0.39",
+ "debug": "^4.4.0",
+ "get-tsconfig": "^4.10.0",
+ "is-bun-module": "^2.0.0",
+ "stable-hash": "^0.0.5",
+ "tinyglobby": "^0.2.13",
+ "unrs-resolver": "^1.6.2"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-resolver-typescript"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*",
+ "eslint-plugin-import-x": "*"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-import": {
+ "optional": true
+ },
+ "eslint-plugin-import-x": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
+ "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y": {
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
+ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aria-query": "^5.3.2",
+ "array-includes": "^3.1.8",
+ "array.prototype.flatmap": "^1.3.2",
+ "ast-types-flow": "^0.0.8",
+ "axe-core": "^4.10.0",
+ "axobject-query": "^4.1.0",
+ "damerau-levenshtein": "^1.0.8",
+ "emoji-regex": "^9.2.2",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^3.3.5",
+ "language-tags": "^1.0.9",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.includes": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-prettier": {
+ "version": "5.5.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
+ "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prettier-linter-helpers": "^1.0.1",
+ "synckit": "^0.11.12"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-plugin-prettier"
+ },
+ "peerDependencies": {
+ "@types/eslint": ">=8.0.0",
+ "eslint": ">=8.0.0",
+ "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
+ "prettier": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/eslint": {
+ "optional": true
+ },
+ "eslint-config-prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/resolve": {
+ "version": "2.0.0-next.5",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
+ "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
+ "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.26.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.2.tgz",
+ "integrity": "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.26.2",
+ "motion-utils": "^12.24.10",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bun-module": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
+ "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.1"
+ }
+ },
+ "node_modules/is-bun-module/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+ "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/language-subtag-registry": {
+ "version": "0.3.23",
+ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
+ "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/language-tags": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
+ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "language-subtag-registry": "^0.3.20"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lowlight": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
+ "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.0.0",
+ "highlight.js": "~11.11.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.562.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
+ "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
+ "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.26.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.26.2.tgz",
+ "integrity": "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.24.10"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.24.10",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
+ "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-postinstall": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
+ "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi-postinstall": "lib/cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/napi-postinstall"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/next": {
+ "version": "16.1.2",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.2.tgz",
+ "integrity": "sha512-SVSWX7wjUUDrIDVqhl4xm/jiOrvYGMG7NzVE/dGzzgs7r3dFGm4V19ia0xn3GDNtHCKM7C9h+5BoimnJBhmt9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.1.2",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.1.2",
+ "@next/swc-darwin-x64": "16.1.2",
+ "@next/swc-linux-arm64-gnu": "16.1.2",
+ "@next/swc-linux-arm64-musl": "16.1.2",
+ "@next/swc-linux-x64-gnu": "16.1.2",
+ "@next/swc-linux-x64-musl": "16.1.2",
+ "@next/swc-win32-arm64-msvc": "16.1.2",
+ "@next/swc-win32-x64-msvc": "16.1.2",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+ "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
+ "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
+ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-linter-helpers": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
+ "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/prettier-plugin-tailwindcss": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
+ "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19"
+ },
+ "peerDependencies": {
+ "@ianvs/prettier-plugin-sort-imports": "*",
+ "@prettier/plugin-hermes": "*",
+ "@prettier/plugin-oxc": "*",
+ "@prettier/plugin-pug": "*",
+ "@shopify/prettier-plugin-liquid": "*",
+ "@trivago/prettier-plugin-sort-imports": "*",
+ "@zackad/prettier-plugin-twig": "*",
+ "prettier": "^3.0",
+ "prettier-plugin-astro": "*",
+ "prettier-plugin-css-order": "*",
+ "prettier-plugin-jsdoc": "*",
+ "prettier-plugin-marko": "*",
+ "prettier-plugin-multiline-arrays": "*",
+ "prettier-plugin-organize-attributes": "*",
+ "prettier-plugin-organize-imports": "*",
+ "prettier-plugin-sort-imports": "*",
+ "prettier-plugin-svelte": "*"
+ },
+ "peerDependenciesMeta": {
+ "@ianvs/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "@prettier/plugin-hermes": {
+ "optional": true
+ },
+ "@prettier/plugin-oxc": {
+ "optional": true
+ },
+ "@prettier/plugin-pug": {
+ "optional": true
+ },
+ "@shopify/prettier-plugin-liquid": {
+ "optional": true
+ },
+ "@trivago/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "@zackad/prettier-plugin-twig": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ },
+ "prettier-plugin-css-order": {
+ "optional": true
+ },
+ "prettier-plugin-jsdoc": {
+ "optional": true
+ },
+ "prettier-plugin-marko": {
+ "optional": true
+ },
+ "prettier-plugin-multiline-arrays": {
+ "optional": true
+ },
+ "prettier-plugin-organize-attributes": {
+ "optional": true
+ },
+ "prettier-plugin-organize-imports": {
+ "optional": true
+ },
+ "prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "prettier-plugin-svelte": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
+ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.3"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rehype-highlight": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
+ "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "lowlight": "^3.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.55.1",
+ "@rollup/rollup-android-arm64": "4.55.1",
+ "@rollup/rollup-darwin-arm64": "4.55.1",
+ "@rollup/rollup-darwin-x64": "4.55.1",
+ "@rollup/rollup-freebsd-arm64": "4.55.1",
+ "@rollup/rollup-freebsd-x64": "4.55.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.55.1",
+ "@rollup/rollup-linux-arm64-musl": "4.55.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.55.1",
+ "@rollup/rollup-linux-loong64-musl": "4.55.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.55.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.55.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-musl": "4.55.1",
+ "@rollup/rollup-openbsd-x64": "4.55.1",
+ "@rollup/rollup-openharmony-arm64": "4.55.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.55.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.55.1",
+ "@rollup/rollup-win32-x64-gnu": "4.55.1",
+ "@rollup/rollup-win32-x64-msvc": "4.55.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stable-hash": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
+ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string.prototype.includes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
+ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
+ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/synckit": {
+ "version": "0.11.12",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
+ "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pkgr/core": "^0.2.9"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/synckit"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+ "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/test-exclude": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+ "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^9.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
+ "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tsconfig-paths/node_modules/json5": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+ "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.53.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz",
+ "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.53.0",
+ "@typescript-eslint/parser": "8.53.0",
+ "@typescript-eslint/typescript-estree": "8.53.0",
+ "@typescript-eslint/utils": "8.53.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
+ "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unrs-resolver": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
+ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "napi-postinstall": "^0.3.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unrs-resolver"
+ },
+ "optionalDependencies": {
+ "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
+ "@unrs/resolver-binding-android-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-x64": "1.11.1",
+ "@unrs/resolver-binding-freebsd-x64": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
+ "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
+ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..fe465a384812500ca83a681a37098b86870448cc
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint",
+ "lint:fix": "next lint --fix",
+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest",
+ "test:coverage": "vitest --coverage"
+ },
+ "dependencies": {
+ "@tailwindcss/typography": "^0.5.19",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.26.2",
+ "lucide-react": "^0.562.0",
+ "next": "16.1.2",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "remark-gfm": "^4.0.1",
+ "tailwind-merge": "^3.4.0"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.3",
+ "@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@typescript-eslint/eslint-plugin": "^8.53.0",
+ "@typescript-eslint/parser": "^8.53.0",
+ "@vitejs/plugin-react": "^4.5.2",
+ "@vitest/coverage-v8": "^3.2.4",
+ "eslint": "^9",
+ "eslint-config-next": "16.1.2",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.5",
+ "jsdom": "^26.1.0",
+ "prettier": "^3.8.0",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "tailwindcss": "^4",
+ "typescript": "^5",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..61e36849cf7cfa9f1f71b4a3964a4953e3e243d3
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..f2c5daa4748cf36414b3e0542347a2b3cf12ff73
--- /dev/null
+++ b/frontend/prettier.config.js
@@ -0,0 +1,81 @@
+/**
+ * Prettier Configuration for Next.js Frontend
+ *
+ * This configuration ensures consistent code formatting across the
+ * RAG Chatbot frontend codebase.
+ *
+ * @see https://prettier.io/docs/en/configuration.html
+ */
+
+/** @type {import('prettier').Config} */
+const config = {
+ // =============================================
+ // Basic Formatting Rules
+ // =============================================
+
+ // Use single quotes for strings
+ singleQuote: true,
+
+ // Add semicolons at the end of statements
+ semi: true,
+
+ // Use 2 spaces for indentation
+ tabWidth: 2,
+
+ // Don't use tabs, use spaces
+ useTabs: false,
+
+ // Maximum line length before wrapping
+ printWidth: 80,
+
+ // =============================================
+ // Trailing Commas & Brackets
+ // =============================================
+
+ // Add trailing commas where valid in ES5 (objects, arrays, etc.)
+ trailingComma: 'es5',
+
+ // Put the > of a multi-line element at the end of the last line
+ bracketSameLine: false,
+
+ // Include parentheses around a sole arrow function parameter
+ arrowParens: 'always',
+
+ // =============================================
+ // JSX Formatting
+ // =============================================
+
+ // Use single quotes in JSX
+ jsxSingleQuote: false,
+
+ // =============================================
+ // Plugins
+ // =============================================
+
+ // Tailwind CSS class sorting plugin
+ plugins: ['prettier-plugin-tailwindcss'],
+
+ // =============================================
+ // File-specific Overrides
+ // =============================================
+
+ overrides: [
+ {
+ // JSON files should use 2-space indentation
+ files: '*.json',
+ options: {
+ tabWidth: 2,
+ },
+ },
+ {
+ // Markdown files have wider print width
+ files: '*.md',
+ options: {
+ printWidth: 100,
+ proseWrap: 'always',
+ },
+ },
+ ],
+};
+
+export default config;
diff --git a/frontend/public/file.svg b/frontend/public/file.svg
new file mode 100644
index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862
--- /dev/null
+++ b/frontend/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg
new file mode 100644
index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb
--- /dev/null
+++ b/frontend/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/next.svg b/frontend/public/next.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398
--- /dev/null
+++ b/frontend/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg
new file mode 100644
index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9
--- /dev/null
+++ b/frontend/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/window.svg b/frontend/public/window.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651
--- /dev/null
+++ b/frontend/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c
Binary files /dev/null and b/frontend/src/app/favicon.ico differ
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..9dc3c100283603ddad69219b9f43aa2ff3f1f6ed
--- /dev/null
+++ b/frontend/src/app/globals.css
@@ -0,0 +1,438 @@
+/**
+ * Global Styles for RAG Chatbot Frontend
+ *
+ * This file contains global CSS variables, base styles, and
+ * utility classes that complement Tailwind CSS.
+ *
+ * Color Theme: Modern Purple AI-Assistant Aesthetic
+ * ================================================
+ * The purple color palette was chosen to convey intelligence, creativity,
+ * and technological sophistication - qualities often associated with AI assistants.
+ * Purple combines the stability of blue with the energy of red, creating a
+ * balanced, professional appearance suitable for a chatbot interface.
+ *
+ * @see https://tailwindcss.com/docs/adding-custom-styles
+ */
+
+@import 'tailwindcss';
+@plugin "@tailwindcss/typography";
+@import "highlight.js/styles/atom-one-dark.css";
+
+/**
+ * CSS Custom Properties (Design Tokens)
+ *
+ * These variables define the core design tokens for the application,
+ * enabling easy theming and dark mode support.
+ *
+ * Theme Selection Logic:
+ * - When `.dark` class is present on html -> dark mode (explicit override)
+ * - When `.light` class is present on html -> light mode (explicit override)
+ * - When neither class is present -> follow system preference
+ */
+
+/**
+ * Light Mode Theme (default or explicit via .light class)
+ *
+ * Applied by default and when the user explicitly selects light mode.
+ *
+ * Purple Palette Accessibility Notes:
+ * -----------------------------------
+ * - Primary 500 (#a855f7): Main brand purple, vibrant and modern
+ * - Primary 700 (#7c3aed): Used for text on white - achieves 5.0:1 contrast (WCAG AA)
+ * - Button text uses white (#ffffff) on purple-500 for 4.58:1 contrast (WCAG AA for large text)
+ * - For critical small text on purple backgrounds, use darker purple shades
+ */
+:root,
+:root.light {
+ /**
+ * Primary Brand Colors - Modern Purple Palette
+ *
+ * This gradient from light lavender (50) to deep purple (900) provides
+ * versatility for backgrounds, borders, text, and interactive states.
+ * The purple family evokes creativity, wisdom, and technological innovation.
+ */
+ --color-primary-50: #faf5ff; /* Very light lavender - subtle backgrounds, hover states */
+ --color-primary-100: #f3e8ff; /* Light lavender - secondary backgrounds */
+ --color-primary-200: #e9d5ff; /* Soft purple - selection backgrounds in light mode */
+ --color-primary-300: #d8b4fe; /* Medium-light purple - decorative elements */
+ --color-primary-400: #c084fc; /* Medium purple - icons, secondary elements */
+ --color-primary-500: #a855f7; /* Main purple - primary buttons, key accents */
+ --color-primary-600: #9333ea; /* Rich purple - active states */
+ --color-primary-700: #7c3aed; /* Dark purple - hover states, accessible text */
+ --color-primary-800: #6b21a8; /* Deep purple - pressed states */
+ --color-primary-900: #581c87; /* Darkest purple - text on light backgrounds */
+
+ /**
+ * Accessible Primary Text Color
+ *
+ * #7c3aed (purple-700) provides 5.0:1 contrast ratio on white (#ffffff),
+ * exceeding the WCAG AA requirement of 4.5:1 for normal text.
+ * This ensures readability for links, labels, and highlighted text
+ * while maintaining the purple brand identity.
+ *
+ * Contrast verification: https://webaim.org/resources/contrastchecker/
+ * - #7c3aed on #ffffff = 5.0:1 (passes WCAG AA for normal text)
+ * - #7c3aed on #f8fafc = 4.85:1 (passes WCAG AA for large text, AAA for UI components)
+ */
+ --color-primary-text: #7c3aed;
+ /* WCAG AA: 5.0:1 on white */
+
+ /**
+ * Primary Button Text Color
+ *
+ * White (#ffffff) text on purple-500 (#a855f7) background provides
+ * 4.58:1 contrast ratio. This passes WCAG AA for large text (18pt+)
+ * and UI components. For buttons with smaller text, consider using
+ * a darker purple background (600 or 700) to achieve higher contrast.
+ *
+ * Contrast verification:
+ * - #ffffff on #a855f7 = 4.58:1 (passes WCAG AA for large text/UI)
+ * - #ffffff on #9333ea = 5.69:1 (passes WCAG AA for all text sizes)
+ */
+ --color-primary-button-text: #ffffff;
+ /* WCAG AA: 4.58:1 on primary-500 (large text/UI), 5.69:1 on primary-600 */
+
+ /* Background colors */
+ --background: #ffffff;
+ --background-secondary: #f8fafc;
+ --background-tertiary: #f1f5f9;
+
+ /* Text colors */
+ --foreground: #0f172a;
+ --foreground-secondary: #475569;
+ --foreground-muted: #64748b;
+ /* WCAG AA: 4.76:1 on white, 4.55:1 on bg-secondary */
+
+ /**
+ * Border Colors
+ *
+ * --border: Decorative borders for visual separation (cards, dividers)
+ * --border-ui: Essential UI component borders (inputs, selects) - higher contrast
+ * --border-focus: Focus ring color using purple-500 for brand consistency
+ */
+ --border: #e2e8f0;
+ /* Decorative borders - use --border-ui for essential UI */
+ --border-ui: #767f8c;
+ /* WCAG AA: 4.05:1 on white for essential UI components */
+ --border-focus: #a855f7;
+ /* Purple-500: Visible focus indicator matching brand color */
+
+ /* Status colors */
+ --success: #22c55e;
+ --warning: #f59e0b;
+ --error: #ef4444;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
+
+ /* Border radius */
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-full: 9999px;
+
+ /* Transitions */
+ --transition-fast: 150ms ease;
+ --transition-normal: 200ms ease;
+ --transition-slow: 300ms ease;
+
+ /* Easing functions */
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+}
+
+/**
+ * Dark Mode Theme via System Preference
+ *
+ * Automatically activated when the user's OS prefers dark color scheme
+ * AND no explicit theme class (.light or .dark) is set on the html element.
+ * This respects system preference while allowing manual override.
+ *
+ * Dark Mode Purple Adjustments:
+ * -----------------------------
+ * - Focus border uses purple-400 (#c084fc) for better visibility on dark backgrounds
+ * - Lighter purple shades become more prominent to maintain visual hierarchy
+ * - Shadows are intensified for depth perception on dark surfaces
+ */
+@media (prefers-color-scheme: dark) {
+ :root:not(.light):not(.dark) {
+ --background: #0f172a;
+ --background-secondary: #1e293b;
+ --background-tertiary: #334155;
+
+ --foreground: #f8fafc;
+ --foreground-secondary: #cbd5e1;
+ --foreground-muted: #a3afc0;
+ /* WCAG AA: 8.03:1 on bg, 6.58:1 on bg-secondary, 4.66:1 on bg-tertiary */
+
+ --border: #334155;
+ /* Decorative borders - use --border-ui for essential UI */
+ --border-ui: #64748b;
+ /* WCAG AA: 3.75:1 on dark bg for essential UI components */
+ --border-focus: #c084fc;
+ /* Purple-400: Brighter purple for visibility on dark backgrounds */
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
+ }
+}
+
+/**
+ * Dark Mode Theme via Explicit Class
+ *
+ * Applied when the user explicitly selects dark mode via the theme toggle.
+ * This overrides the system preference, allowing users to choose dark mode
+ * even when their OS is set to light mode.
+ *
+ * Matches the system-preference dark mode settings for consistency.
+ */
+:root.dark {
+ --background: #0f172a;
+ --background-secondary: #1e293b;
+ --background-tertiary: #334155;
+
+ --foreground: #f8fafc;
+ --foreground-secondary: #cbd5e1;
+ --foreground-muted: #a3afc0;
+ /* WCAG AA: 8.03:1 on bg, 6.58:1 on bg-secondary, 4.66:1 on bg-tertiary */
+
+ --border: #334155;
+ /* Decorative borders - use --border-ui for essential UI */
+ --border-ui: #64748b;
+ /* WCAG AA: 3.75:1 on dark bg for essential UI components */
+ --border-focus: #c084fc;
+ /* Purple-400: Brighter purple for visibility on dark backgrounds */
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
+}
+
+/**
+ * Base Element Styles
+ *
+ * Apply consistent base styles to HTML elements.
+ */
+html {
+ /* Smooth scrolling for anchor links */
+ scroll-behavior: smooth;
+}
+
+body {
+ /* Apply design tokens to body */
+ background-color: var(--background);
+ color: var(--foreground);
+
+ /* Optimize text rendering */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/**
+ * Focus Styles
+ *
+ * Consistent focus ring for accessibility.
+ * Uses the purple brand color for cohesive visual identity
+ * while maintaining clear focus indication for keyboard navigation.
+ */
+:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: 2px;
+}
+
+/**
+ * Selection Styles
+ *
+ * Custom text selection colors using the purple palette.
+ *
+ * Light mode: Light purple background (#e9d5ff / primary-200) with dark purple text
+ * Dark mode: Dark purple background (#7c3aed / primary-700) with light lavender text
+ *
+ * These combinations ensure selected text remains readable while
+ * reinforcing the purple brand identity throughout the interface.
+ */
+::selection {
+ background-color: var(--color-primary-200);
+ /* #e9d5ff - soft purple selection background */
+ color: var(--color-primary-900);
+ /* #581c87 - dark purple text for contrast */
+}
+
+/**
+ * Dark mode selection styles via system preference
+ * Applied when no explicit theme class is set and system prefers dark mode
+ */
+@media (prefers-color-scheme: dark) {
+ :root:not(.light):not(.dark) ::selection {
+ background-color: var(--color-primary-700);
+ /* #7c3aed - rich purple background */
+ color: var(--color-primary-50);
+ /* #faf5ff - light lavender text */
+ }
+}
+
+/**
+ * Dark mode selection styles via explicit class
+ * Applied when .dark class is set on html element
+ */
+:root.dark ::selection {
+ background-color: var(--color-primary-700);
+ /* #7c3aed - rich purple background */
+ color: var(--color-primary-50);
+ /* #faf5ff - light lavender text */
+}
+
+/**
+ * Scrollbar Styles (Webkit)
+ *
+ * Custom scrollbar appearance for webkit browsers.
+ */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--background-secondary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--foreground-muted);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--foreground-secondary);
+}
+
+/**
+ * Custom Animation Keyframes
+ */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/**
+ * Fade and slide in animation for staggered list items.
+ * Used by SourceCitations component for smooth entrance effects.
+ */
+@keyframes fadeSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(0.5rem);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/**
+ * Utility Classes
+ */
+.animate-fade-in {
+ animation: fadeIn var(--transition-normal) ease-out;
+}
+
+.animate-slide-up {
+ animation: slideUp var(--transition-slow) ease-out;
+}
+
+.animate-pulse-custom {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+/**
+ * Thermal Comfort Illustration Animations
+ *
+ * Custom keyframes for the ThermalComfortIllustration component.
+ * These provide subtle, professional animations that enhance the
+ * visual appeal without being distracting.
+ */
+
+/**
+ * Gentle floating animation for the main illustration container.
+ * Creates a subtle up-and-down motion suggesting thermal air currents.
+ */
+@keyframes thermalFloat {
+
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+
+ 50% {
+ transform: translateY(-4px);
+ }
+}
+
+/**
+ * Soft pulse animation for comfort zone elements.
+ * Provides a gentle "breathing" effect to indicate active thermal monitoring.
+ */
+@keyframes thermalPulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 50% {
+ opacity: 0.7;
+ transform: scale(0.98);
+ }
+}
+
+/**
+ * Wave animation for air flow lines.
+ * Creates a gentle horizontal shift suggesting air movement.
+ */
+@keyframes thermalWave {
+
+ 0%,
+ 100% {
+ transform: translateX(0);
+ opacity: 0.6;
+ }
+
+ 50% {
+ transform: translateX(3px);
+ opacity: 0.9;
+ }
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2cfd1c00b070b95b27a467d2540c8586f13f2e02
--- /dev/null
+++ b/frontend/src/app/layout.tsx
@@ -0,0 +1,124 @@
+/**
+ * Root Layout Component
+ *
+ * This is the root layout for the Next.js App Router. It wraps all pages
+ * and provides the base HTML structure, fonts, and global styles.
+ *
+ * @see https://nextjs.org/docs/app/building-your-application/routing/layouts-and-templates
+ */
+
+import type { Metadata, Viewport } from 'next';
+import { Inter, JetBrains_Mono } from 'next/font/google';
+import './globals.css';
+import { Providers } from './providers';
+
+/**
+ * Inter font configuration.
+ *
+ * Inter is used as the primary sans-serif font for its excellent
+ * readability and modern appearance.
+ */
+const inter = Inter({
+ variable: '--font-inter',
+ subsets: ['latin'],
+ display: 'swap',
+});
+
+/**
+ * JetBrains Mono font configuration.
+ *
+ * Used for code blocks and monospace text to ensure
+ * consistent character widths.
+ */
+const jetbrainsMono = JetBrains_Mono({
+ variable: '--font-mono',
+ subsets: ['latin'],
+ display: 'swap',
+});
+
+/**
+ * Application metadata for SEO and social sharing.
+ */
+export const metadata: Metadata = {
+ title: {
+ default: 'pythermalcomfort Chat',
+ template: '%s | pythermalcomfort Chat',
+ },
+ description:
+ 'Ask questions about thermal comfort standards and the pythermalcomfort Python library. Powered by RAG with multiple LLM providers.',
+ keywords: [
+ 'thermal comfort',
+ 'pythermalcomfort',
+ 'ASHRAE',
+ 'PMV',
+ 'PPD',
+ 'adaptive comfort',
+ 'building science',
+ 'HVAC',
+ ],
+ authors: [{ name: 'pythermalcomfort Team' }],
+ creator: 'pythermalcomfort',
+ openGraph: {
+ type: 'website',
+ locale: 'en_US',
+ title: 'pythermalcomfort Chat',
+ description:
+ 'AI-powered assistant for thermal comfort standards and pythermalcomfort library.',
+ siteName: 'pythermalcomfort Chat',
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+};
+
+/**
+ * Viewport configuration for responsive design.
+ */
+export const viewport: Viewport = {
+ width: 'device-width',
+ initialScale: 1,
+ themeColor: [
+ { media: '(prefers-color-scheme: light)', color: '#ffffff' },
+ { media: '(prefers-color-scheme: dark)', color: '#0f172a' },
+ ],
+};
+
+/**
+ * Root Layout Props
+ */
+interface RootLayoutProps {
+ /** Page content to render */
+ children: React.ReactNode;
+}
+
+/**
+ * Root Layout Component
+ *
+ * Provides the base HTML structure for all pages in the application.
+ * Includes font loading, global styles, and common meta tags.
+ *
+ * @param props - Component props containing children to render
+ * @returns The root HTML structure wrapping all page content
+ */
+export default function RootLayout({
+ children,
+}: RootLayoutProps): React.ReactElement {
+ return (
+
+
+ {/*
+ Main application content
+ The flex layout ensures footer stays at bottom
+ */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..58d6545db0581c71a0182e7a033f2152492f792e
--- /dev/null
+++ b/frontend/src/app/loading.tsx
@@ -0,0 +1,30 @@
+/**
+ * Loading Component
+ *
+ * This component is displayed while page content is loading.
+ * Next.js automatically shows this during navigation.
+ *
+ * @see https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
+ */
+
+/**
+ * Loading Skeleton
+ *
+ * Displays a loading animation while the page content loads.
+ * Uses a pulsing skeleton pattern for visual feedback.
+ *
+ * @returns Loading state UI
+ */
+export default function Loading(): React.ReactElement {
+ return (
+
+
+ {/* Spinning loader */}
+
+
+ {/* Loading text */}
+
Loading...
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9fd84073671fbc5139b460a777b484274590fc40
--- /dev/null
+++ b/frontend/src/app/page.tsx
@@ -0,0 +1,107 @@
+/**
+ * Home Page Component
+ *
+ * The main landing page for the pythermalcomfort RAG Chatbot.
+ * Displays the full chat interface for interacting with the
+ * AI assistant about thermal comfort standards.
+ *
+ * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts
+ */
+
+'use client';
+
+import { MessageSquare } from 'lucide-react';
+import { ChatContainer, Sidebar } from '@/components/chat';
+
+/**
+ * Home Page
+ *
+ * Renders the complete chat interface for the pythermalcomfort
+ * RAG chatbot. The page uses a full-height layout with the
+ * ChatContainer taking up the entire viewport.
+ *
+ * @remarks
+ * ## Layout Structure
+ *
+ * The page uses a Gemini-style layout with:
+ * - App title fixed in the top-left corner (like "Gemini" branding)
+ * - Collapsible sidebar showing current provider/model
+ * - Chat container centered in the remaining space
+ * - Full viewport height
+ *
+ * ## Responsive Design
+ *
+ * - On mobile: Sidebar hidden, chat fills screen
+ * - On tablet/desktop: Sidebar visible, collapsible
+ *
+ * ## Chat Configuration
+ *
+ * The ChatContainer is configured with:
+ * - No internal header (title is at page level)
+ * - Empty initial messages (fresh conversation)
+ * - No persistence callback (can be added for localStorage support)
+ *
+ * @returns The home page with chat interface
+ */
+export default function HomePage(): React.ReactElement {
+ return (
+
+ {/*
+ Page Header - Gemini-style branding in top-left
+
+ Fixed position at top-left of the page, outside the chat area.
+ This mimics how Gemini shows its logo/name.
+ */}
+
+
+
+
+ pythermalcomfort Chat
+
+
+
+ AI-powered answers from scientific sources and official documentation
+
+
+
+ {/*
+ Main Content Area - Sidebar + Chat
+
+ Horizontal layout with collapsible sidebar on left
+ and chat container filling the remaining space.
+ */}
+
+ {/*
+ Left Sidebar - Provider/Model Info
+
+ Shows current LLM provider and model name.
+ Collapsible to icon-only mode.
+ Hidden on mobile for better space utilization.
+ */}
+
+
+ {/*
+ ChatContainer - Main chat interface
+
+ Fills the remaining space after sidebar.
+ No internal header - the title is shown at page level above.
+ */}
+
+
+
+ );
+}
diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..075677a08dba7822e5ccfa73367013b28c9d7313
--- /dev/null
+++ b/frontend/src/app/providers.tsx
@@ -0,0 +1,28 @@
+/**
+ * Client-side Providers Wrapper
+ *
+ * Wraps children with all client-side context providers.
+ * This component is used by the root layout to provide
+ * shared state to all pages.
+ *
+ * @module app/providers
+ */
+
+'use client';
+
+import type { ReactNode } from 'react';
+import { ProviderProvider } from '@/contexts/provider-context';
+
+interface ProvidersProps {
+ children: ReactNode;
+}
+
+/**
+ * Providers Component
+ *
+ * Wraps the application with all necessary context providers.
+ * Add additional providers here as needed.
+ */
+export function Providers({ children }: ProvidersProps): React.ReactElement {
+ return {children} ;
+}
diff --git a/frontend/src/components/chat/__tests__/__snapshots__/empty-state.test.tsx.snap b/frontend/src/components/chat/__tests__/__snapshots__/empty-state.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..f0f4a74f9cd9d51f2b0a941e5d9704c09e4e0d4f
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/__snapshots__/empty-state.test.tsx.snap
@@ -0,0 +1,368 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EmptyState > snapshots > should render correctly (snapshot) 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ask about thermal comfort and pythermalcomfort
+
+
+ Get answers about thermal comfort models, concepts, standards and the pythermalcomfort library. Your questions are answered using scientific sources and official documentations.
+
+
+
+
+ Try asking
+
+
+
+
+
+ What is the PMV model and how do I calculate it?
+
+
+
+
+
+ How do I use the adaptive comfort model in pythermalcomfort?
+
+
+
+
+
+ What are the inputs for calculating thermal comfort indices?
+
+
+
+
+
+ Explain the difference between SET and PMV thermal comfort models.
+
+
+
+
+
+
+`;
diff --git a/frontend/src/components/chat/__tests__/__snapshots__/error-state.test.tsx.snap b/frontend/src/components/chat/__tests__/__snapshots__/error-state.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..31a7d71b0bdac0e7979b2a254b6ac769245a7ff7
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/__snapshots__/error-state.test.tsx.snap
@@ -0,0 +1,222 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ErrorState > snapshots > should render general type correctly (snapshot) 1`] = `
+
+
+
+
+
+
+
+
+ Oops! Something Went Wrong
+
+
+ A custom error message for the snapshot test.
+
+
+
+
+
+
+ Try Again
+
+
+
+
+
+`;
+
+exports[`ErrorState > snapshots > should render network type correctly (snapshot) 1`] = `
+
+
+
+
+
+
+
+
+ Connection Lost
+
+
+ Unable to connect. Please check your internet connection.
+
+
+
+
+
+
+ Try Again
+
+
+
+
+
+`;
+
+exports[`ErrorState > snapshots > should render quota type correctly (snapshot) 1`] = `
+
+
+
+
+
+
+
+
+ Service Temporarily Unavailable
+
+
+ Our service is currently at capacity. Please wait a moment.
+
+
+
+
+
+ Try again in 45s
+
+
+
+
+
+
+ Please wait...
+
+
+
+
+
+`;
diff --git a/frontend/src/components/chat/__tests__/empty-state.test.tsx b/frontend/src/components/chat/__tests__/empty-state.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1a859652787590950ba895b195adf9eb589c377f
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/empty-state.test.tsx
@@ -0,0 +1,375 @@
+/**
+ * Unit Tests for EmptyState Component
+ *
+ * Comprehensive test coverage for the empty state welcome component.
+ * Tests rendering, example question interactions, accessibility features,
+ * and edge cases.
+ *
+ * @module components/chat/__tests__/empty-state.test
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { EmptyState } from '../empty-state';
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+/**
+ * Mock lucide-react icons for faster tests and to avoid lazy loading issues.
+ */
+vi.mock('lucide-react', () => ({
+ MessageSquare: ({
+ className,
+ 'aria-hidden': ariaHidden,
+ }: {
+ className?: string;
+ 'aria-hidden'?: boolean | 'true' | 'false';
+ }) => (
+
+ ),
+}));
+
+// ============================================================================
+// Test Fixtures
+// ============================================================================
+
+/**
+ * Example questions that should be displayed in the component.
+ */
+const EXPECTED_QUESTIONS = [
+ 'What is the PMV model and how do I calculate it?',
+ 'How do I use the adaptive comfort model in pythermalcomfort?',
+ 'What are the inputs for calculating thermal comfort indices?',
+ 'Explain the difference between SET and PMV thermal comfort models.',
+];
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('EmptyState', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ // ==========================================================================
+ // Rendering Tests
+ // ==========================================================================
+
+ describe('rendering', () => {
+ it('should render the main title', () => {
+ render( );
+
+ expect(
+ screen.getByText('Ask about thermal comfort and pythermalcomfort')
+ ).toBeInTheDocument();
+ });
+
+ it('should render the subtitle description', () => {
+ render( );
+
+ expect(
+ screen.getByText(/get answers about thermal comfort standards/i)
+ ).toBeInTheDocument();
+ });
+
+ it('should render "Try asking" label', () => {
+ render( );
+
+ expect(screen.getByText('Try asking')).toBeInTheDocument();
+ });
+
+ it('should render all example questions', () => {
+ render( );
+
+ EXPECTED_QUESTIONS.forEach((question) => {
+ expect(screen.getByText(question)).toBeInTheDocument();
+ });
+ });
+
+ it('should render MessageSquare icon for each question', () => {
+ render( );
+
+ const icons = screen.getAllByTestId('message-square-icon');
+ expect(icons).toHaveLength(EXPECTED_QUESTIONS.length);
+ });
+
+ it('should render thermal comfort illustration (SVG)', () => {
+ const { container } = render( );
+
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+ });
+
+ it('should hide illustration from screen readers', () => {
+ const { container } = render( );
+
+ const svg = container.querySelector('svg');
+ expect(svg).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should render example questions as buttons', () => {
+ render( );
+
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(EXPECTED_QUESTIONS.length);
+ });
+ });
+
+ // ==========================================================================
+ // Interaction Tests
+ // ==========================================================================
+
+ describe('interactions', () => {
+ it('should call onExampleClick when first question is clicked', () => {
+ const mockClick = vi.fn();
+
+ render( );
+
+ fireEvent.click(screen.getByText(EXPECTED_QUESTIONS[0]));
+
+ expect(mockClick).toHaveBeenCalledTimes(1);
+ expect(mockClick).toHaveBeenCalledWith(EXPECTED_QUESTIONS[0]);
+ });
+
+ it('should call onExampleClick with correct question for each button', () => {
+ const mockClick = vi.fn();
+
+ render( );
+
+ EXPECTED_QUESTIONS.forEach((question, index) => {
+ fireEvent.click(screen.getByText(question));
+ expect(mockClick).toHaveBeenNthCalledWith(index + 1, question);
+ });
+
+ expect(mockClick).toHaveBeenCalledTimes(EXPECTED_QUESTIONS.length);
+ });
+
+ it('should call onExampleClick multiple times when same question clicked repeatedly', () => {
+ const mockClick = vi.fn();
+
+ render( );
+
+ const firstQuestion = screen.getByText(EXPECTED_QUESTIONS[0]);
+
+ fireEvent.click(firstQuestion);
+ fireEvent.click(firstQuestion);
+ fireEvent.click(firstQuestion);
+
+ expect(mockClick).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ // ==========================================================================
+ // Accessibility Tests
+ // ==========================================================================
+
+ describe('accessibility', () => {
+ it('should have semantic heading hierarchy with h2', () => {
+ render( );
+
+ const heading = screen.getByRole('heading', { level: 2 });
+ expect(heading).toBeInTheDocument();
+ expect(heading).toHaveTextContent('Ask about thermal comfort and pythermalcomfort');
+ });
+
+ it('should have focusable question buttons', () => {
+ render( );
+
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((button) => {
+ button.focus();
+ expect(button).toHaveFocus();
+ });
+ });
+
+ it('should have visible focus styles on buttons', () => {
+ render( );
+
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((button) => {
+ expect(button.className).toMatch(/focus-visible:ring/);
+ });
+ });
+
+ it('should have icons with aria-hidden for decorative elements', () => {
+ render( );
+
+ const icons = screen.getAllByTestId('message-square-icon');
+ icons.forEach((icon) => {
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
+ });
+ });
+
+ it('should support keyboard Enter to activate question buttons', () => {
+ const mockClick = vi.fn();
+
+ render( );
+
+ const firstButton = screen.getAllByRole('button')[0];
+ firstButton.focus();
+
+ // Simulate Enter key (buttons natively handle this)
+ fireEvent.keyDown(firstButton, { key: 'Enter', code: 'Enter' });
+ fireEvent.click(firstButton);
+
+ expect(mockClick).toHaveBeenCalled();
+ });
+
+ it('should support keyboard Space to activate question buttons', () => {
+ const mockClick = vi.fn();
+
+ render( );
+
+ const firstButton = screen.getAllByRole('button')[0];
+ firstButton.focus();
+
+ // Simulate Space key (buttons natively handle this)
+ fireEvent.keyDown(firstButton, { key: ' ', code: 'Space' });
+ fireEvent.click(firstButton);
+
+ expect(mockClick).toHaveBeenCalled();
+ });
+ });
+
+ // ==========================================================================
+ // Styling Tests
+ // ==========================================================================
+
+ describe('styling', () => {
+ it('should apply custom className to container', () => {
+ const { container } = render(
+
+ );
+
+ const outerDiv = container.firstChild;
+ expect(outerDiv).toHaveClass('custom-class', 'mt-8');
+ });
+
+ it('should have animation class for entrance effect', () => {
+ const { container } = render( );
+
+ const outerDiv = container.firstChild;
+ expect(outerDiv).toHaveClass('animate-slide-up');
+ });
+
+ it('should have glassmorphism styling on card', () => {
+ const { container } = render( );
+
+ // Find the card container (child of outer div)
+ const card = container.querySelector('.backdrop-blur-sm');
+ expect(card).toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Edge Cases Tests
+ // ==========================================================================
+
+ describe('edge cases', () => {
+ it('should render without crashing when onExampleClick is a no-op', () => {
+ render( {}} />);
+
+ expect(
+ screen.getByText('Ask about thermal comfort and pythermalcomfort')
+ ).toBeInTheDocument();
+ });
+
+ it('should forward additional HTML attributes', () => {
+ render(
+
+ );
+
+ const container = screen.getByTestId('empty-state');
+ expect(container).toHaveAttribute('title', 'Welcome state');
+ });
+
+ it('should render questions in a grid layout', () => {
+ const { container } = render( );
+
+ const grid = container.querySelector('.grid');
+ expect(grid).toBeInTheDocument();
+ expect(grid).toHaveClass('sm:grid-cols-2');
+ });
+
+ it('should maintain button type as "button"', () => {
+ render( );
+
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((button) => {
+ expect(button).toHaveAttribute('type', 'button');
+ });
+ });
+ });
+
+ // ==========================================================================
+ // Snapshot Tests
+ // ==========================================================================
+
+ describe('snapshots', () => {
+ it('should render correctly (snapshot)', () => {
+ const { container } = render( );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+
+ // ==========================================================================
+ // Integration Tests
+ // ==========================================================================
+
+ describe('integration', () => {
+ it('should render complete empty state with all elements', () => {
+ render( );
+
+ // Title and subtitle
+ expect(
+ screen.getByText('Ask about thermal comfort and pythermalcomfort')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/get answers about thermal comfort/i)
+ ).toBeInTheDocument();
+
+ // "Try asking" label
+ expect(screen.getByText('Try asking')).toBeInTheDocument();
+
+ // All questions as buttons
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(4);
+
+ // All icons
+ const icons = screen.getAllByTestId('message-square-icon');
+ expect(icons).toHaveLength(4);
+ });
+
+ it('should work correctly through a complete user interaction flow', () => {
+ const mockClick = vi.fn();
+
+ render( );
+
+ // Verify initial state
+ expect(mockClick).not.toHaveBeenCalled();
+
+ // Click first question
+ fireEvent.click(screen.getByText(EXPECTED_QUESTIONS[0]));
+ expect(mockClick).toHaveBeenLastCalledWith(EXPECTED_QUESTIONS[0]);
+
+ // Click last question
+ fireEvent.click(screen.getByText(EXPECTED_QUESTIONS[3]));
+ expect(mockClick).toHaveBeenLastCalledWith(EXPECTED_QUESTIONS[3]);
+
+ // Total clicks
+ expect(mockClick).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/frontend/src/components/chat/__tests__/error-state.test.tsx b/frontend/src/components/chat/__tests__/error-state.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d54ecd1973ed3f18543661434cda6f747a8ae937
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/error-state.test.tsx
@@ -0,0 +1,797 @@
+/**
+ * Unit Tests for ErrorState Component
+ *
+ * Comprehensive test coverage for the error display component.
+ * Tests rendering states, countdown timer functionality, interactions,
+ * accessibility features, and visual/snapshot tests.
+ *
+ * @module components/chat/__tests__/error-state.test
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { ErrorState, type ErrorType } from '../error-state';
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+/**
+ * Mock lucide-react icons for faster tests and to avoid lazy loading issues.
+ * Each icon is replaced with a simple span containing a test ID and class.
+ */
+vi.mock('lucide-react', () => ({
+ AlertTriangle: ({
+ className,
+ 'aria-hidden': ariaHidden,
+ }: {
+ className?: string;
+ 'aria-hidden'?: boolean | 'true' | 'false';
+ }) => (
+
+ ),
+ WifiOff: ({
+ className,
+ 'aria-hidden': ariaHidden,
+ }: {
+ className?: string;
+ 'aria-hidden'?: boolean | 'true' | 'false';
+ }) => (
+
+ ),
+ Clock: ({
+ className,
+ 'aria-hidden': ariaHidden,
+ }: {
+ className?: string;
+ 'aria-hidden'?: boolean | 'true' | 'false';
+ }) => (
+
+ ),
+ RefreshCw: ({
+ className,
+ 'aria-hidden': ariaHidden,
+ }: {
+ className?: string;
+ 'aria-hidden'?: boolean | 'true' | 'false';
+ }) => (
+
+ ),
+ X: ({
+ className,
+ 'aria-hidden': ariaHidden,
+ }: {
+ className?: string;
+ 'aria-hidden'?: boolean | 'true' | 'false';
+ }) => (
+
+ ),
+}));
+
+// ============================================================================
+// Test Helpers
+// ============================================================================
+
+/**
+ * Default error messages for each error type.
+ */
+const DEFAULT_MESSAGES: Record = {
+ quota: 'Our service is currently at capacity. Please wait a moment.',
+ network: 'Unable to connect. Please check your internet connection.',
+ general: 'Something went wrong. Please try again.',
+};
+
+/**
+ * Titles displayed for each error type.
+ */
+const ERROR_TITLES: Record = {
+ quota: 'Service Temporarily Unavailable',
+ network: 'Connection Lost',
+ general: 'Oops! Something Went Wrong',
+};
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('ErrorState', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ // ==========================================================================
+ // Rendering Tests
+ // ==========================================================================
+
+ describe('rendering', () => {
+ it('should render quota error type correctly with Clock icon', () => {
+ render( );
+
+ // Should show quota-specific title
+ expect(
+ screen.getByText('Service Temporarily Unavailable')
+ ).toBeInTheDocument();
+
+ // Should show default quota message
+ expect(
+ screen.getByText(DEFAULT_MESSAGES.quota)
+ ).toBeInTheDocument();
+
+ // Clock icon should be used for quota type (main icon)
+ const clockIcons = screen.getAllByTestId('clock-icon');
+ expect(clockIcons.length).toBeGreaterThan(0);
+ });
+
+ it('should render network error type correctly with WifiOff icon', () => {
+ render( );
+
+ // Should show network-specific title
+ expect(screen.getByText('Connection Lost')).toBeInTheDocument();
+
+ // Should show default network message
+ expect(
+ screen.getByText(DEFAULT_MESSAGES.network)
+ ).toBeInTheDocument();
+
+ // WifiOff icon should be present
+ expect(screen.getByTestId('wifi-off-icon')).toBeInTheDocument();
+ });
+
+ it('should render general error type correctly with AlertTriangle icon', () => {
+ render( );
+
+ // Should show general-specific title
+ expect(
+ screen.getByText('Oops! Something Went Wrong')
+ ).toBeInTheDocument();
+
+ // Should show default general message
+ expect(
+ screen.getByText(DEFAULT_MESSAGES.general)
+ ).toBeInTheDocument();
+
+ // AlertTriangle icon should be present
+ expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
+ });
+
+ it('should show default message when no custom message provided', () => {
+ render( );
+
+ expect(
+ screen.getByText(DEFAULT_MESSAGES.quota)
+ ).toBeInTheDocument();
+ });
+
+ it('should show custom message when provided', () => {
+ const customMessage = 'A custom error message for testing purposes.';
+
+ render( );
+
+ expect(screen.getByText(customMessage)).toBeInTheDocument();
+ // Default message should not be present
+ expect(
+ screen.queryByText(DEFAULT_MESSAGES.quota)
+ ).not.toBeInTheDocument();
+ });
+
+ it('should show dismiss button when onDismiss callback provided', () => {
+ const mockDismiss = vi.fn();
+
+ render( );
+
+ const dismissButton = screen.getByLabelText('Dismiss error');
+ expect(dismissButton).toBeInTheDocument();
+ expect(screen.getByTestId('x-icon')).toBeInTheDocument();
+ });
+
+ it('should hide dismiss button when onDismiss not provided', () => {
+ render( );
+
+ expect(screen.queryByLabelText('Dismiss error')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('x-icon')).not.toBeInTheDocument();
+ });
+
+ it('should show retry button when onRetry callback provided', () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ expect(retryButton).toBeInTheDocument();
+ expect(screen.getByTestId('refresh-icon')).toBeInTheDocument();
+ });
+
+ it('should hide retry button when onRetry not provided', () => {
+ render( );
+
+ expect(
+ screen.queryByRole('button', { name: /try again/i })
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('refresh-icon')).not.toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Countdown Timer Tests
+ // ==========================================================================
+
+ describe('countdown timer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should display countdown timer for quota errors with retryAfter', () => {
+ render( );
+
+ // Should show the countdown timer text
+ expect(screen.getByText(/try again in/i)).toBeInTheDocument();
+ });
+
+ it('should format countdown correctly for seconds (e.g., "45s")', () => {
+ render( );
+
+ expect(screen.getByText(/45s/)).toBeInTheDocument();
+ });
+
+ it('should format countdown correctly for minutes (e.g., "1:30")', () => {
+ render( );
+
+ expect(screen.getByText(/1:30/)).toBeInTheDocument();
+ });
+
+ it('should format countdown correctly for exact minutes (e.g., "2:00")', () => {
+ render( );
+
+ expect(screen.getByText(/2:00/)).toBeInTheDocument();
+ });
+
+ it('should decrement countdown every second', async () => {
+ render( );
+
+ // Initially shows 5s
+ expect(screen.getByText(/5s/)).toBeInTheDocument();
+
+ // Advance by 1 second
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText(/4s/)).toBeInTheDocument();
+
+ // Advance by another second
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText(/3s/)).toBeInTheDocument();
+
+ // Advance by another second
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText(/2s/)).toBeInTheDocument();
+ });
+
+ it('should disable retry button during countdown', () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ const retryButton = screen.getByRole('button', { name: /please wait/i });
+ expect(retryButton).toBeDisabled();
+ });
+
+ it('should enable retry button when countdown reaches zero', async () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ // Initially disabled
+ expect(
+ screen.getByRole('button', { name: /please wait/i })
+ ).toBeDisabled();
+
+ // Advance past countdown
+ await act(async () => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ // Should now be enabled with "Try Again" text
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ expect(retryButton).not.toBeDisabled();
+ });
+
+ it('should show "Ready to retry" text when countdown reaches zero', async () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ // Advance past countdown
+ await act(async () => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ expect(screen.getByText(/ready to retry/i)).toBeInTheDocument();
+ });
+
+ it('should not show countdown for network errors even with retryAfter', () => {
+ render( );
+
+ // Network errors should not show countdown timer area
+ expect(screen.queryByText(/try again in/i)).not.toBeInTheDocument();
+ });
+
+ it('should not show countdown for general errors even with retryAfter', () => {
+ render( );
+
+ // General errors should not show countdown timer area
+ expect(screen.queryByText(/try again in/i)).not.toBeInTheDocument();
+ });
+
+ it('should not disable retry button for network errors', () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ // Network errors should have enabled retry button
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ expect(retryButton).not.toBeDisabled();
+ });
+ });
+
+ // ==========================================================================
+ // Interaction Tests
+ // ==========================================================================
+
+ describe('interactions', () => {
+ it('should call onRetry when retry button is clicked', () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ fireEvent.click(retryButton);
+
+ expect(mockRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onDismiss when dismiss button is clicked', () => {
+ const mockDismiss = vi.fn();
+
+ render( );
+
+ const dismissButton = screen.getByLabelText('Dismiss error');
+ fireEvent.click(dismissButton);
+
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call onRetry when retry is disabled during countdown', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const mockRetry = vi.fn();
+
+ render( );
+
+ const retryButton = screen.getByRole('button', { name: /please wait/i });
+
+ // Try to click the disabled button
+ fireEvent.click(retryButton);
+
+ expect(mockRetry).not.toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+
+ it('should call onRetry after countdown completes', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const mockRetry = vi.fn();
+
+ render( );
+
+ // Wait for countdown to complete
+ await act(async () => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ // Now click should work
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ fireEvent.click(retryButton);
+
+ expect(mockRetry).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+ });
+
+ // ==========================================================================
+ // Accessibility Tests
+ // ==========================================================================
+
+ describe('accessibility', () => {
+ it('should have role="alert" attribute', () => {
+ render( );
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toBeInTheDocument();
+ });
+
+ it('should have aria-live="assertive" attribute', () => {
+ render( );
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveAttribute('aria-live', 'assertive');
+ });
+
+ it('should have icons with aria-hidden="true"', () => {
+ render(
+
+ );
+
+ // Main icon (AlertTriangle for general)
+ const alertIcon = screen.getByTestId('alert-triangle-icon');
+ expect(alertIcon).toHaveAttribute('aria-hidden', 'true');
+
+ // Refresh icon in retry button
+ const refreshIcon = screen.getByTestId('refresh-icon');
+ expect(refreshIcon).toHaveAttribute('aria-hidden', 'true');
+
+ // X icon in dismiss button
+ const xIcon = screen.getByTestId('x-icon');
+ expect(xIcon).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should have countdown with aria-live="polite" for updates', () => {
+ render( );
+
+ // Find the countdown container - it should have aria-live="polite"
+ const countdownContainer = screen.getByText(/try again in/i).closest('div');
+ expect(countdownContainer).toHaveAttribute('aria-live', 'polite');
+ });
+
+ it('should have dismiss button with aria-label', () => {
+ render( );
+
+ const dismissButton = screen.getByLabelText('Dismiss error');
+ expect(dismissButton).toBeInTheDocument();
+ expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss error');
+ });
+
+ it('should be keyboard navigable', () => {
+ const mockRetry = vi.fn();
+ const mockDismiss = vi.fn();
+
+ render(
+
+ );
+
+ // Dismiss button should be focusable
+ const dismissButton = screen.getByLabelText('Dismiss error');
+ dismissButton.focus();
+ expect(dismissButton).toHaveFocus();
+
+ // Retry button should be focusable
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ retryButton.focus();
+ expect(retryButton).toHaveFocus();
+
+ // Press Enter to activate retry (using keyboard event)
+ fireEvent.keyDown(retryButton, { key: 'Enter', code: 'Enter' });
+ fireEvent.keyUp(retryButton, { key: 'Enter', code: 'Enter' });
+ // Note: button click events are triggered by Enter key natively in tests,
+ // but we verify focus works correctly
+ });
+
+ it('should have visible focus styles on buttons', () => {
+ render(
+
+ );
+
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ const dismissButton = screen.getByLabelText('Dismiss error');
+
+ // Check for focus-visible ring classes
+ expect(retryButton.className).toMatch(/focus-visible:ring/);
+ expect(dismissButton.className).toMatch(/focus-visible:ring/);
+ });
+ });
+
+ // ==========================================================================
+ // Snapshot/Visual Tests
+ // ==========================================================================
+
+ describe('snapshots', () => {
+ it('should render quota type correctly (snapshot)', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it('should render network type correctly (snapshot)', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it('should render general type correctly (snapshot)', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+
+ // ==========================================================================
+ // Edge Cases Tests
+ // ==========================================================================
+
+ describe('edge cases', () => {
+ it('should handle retryAfter of 0 correctly', () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ // Retry button should be enabled immediately
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ expect(retryButton).not.toBeDisabled();
+ });
+
+ it('should handle undefined retryAfter correctly', () => {
+ const mockRetry = vi.fn();
+
+ render( );
+
+ // Should not show countdown area when retryAfter is undefined
+ expect(screen.queryByText(/try again in/i)).not.toBeInTheDocument();
+ });
+
+ it('should apply custom className', () => {
+ render( );
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveClass('custom-test-class', 'mt-8');
+ });
+
+ it('should render both retry and dismiss buttons together', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: /try again/i })
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Dismiss error')).toBeInTheDocument();
+ });
+
+ it('should handle very large retryAfter values', () => {
+ render( );
+
+ // Should format large values correctly (60:00 for 1 hour)
+ expect(screen.getByText(/60:00/)).toBeInTheDocument();
+ });
+
+ it('should handle countdown reset when retryAfter prop changes', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByText(/10s/)).toBeInTheDocument();
+
+ // Advance some time
+ await act(async () => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(screen.getByText(/7s/)).toBeInTheDocument();
+
+ // Change retryAfter prop
+ rerender( );
+
+ // Should reset to new value
+ expect(screen.getByText(/20s/)).toBeInTheDocument();
+
+ vi.useRealTimers();
+ });
+
+ it('should forward additional HTML attributes', () => {
+ render(
+
+ );
+
+ const alert = screen.getByTestId('custom-error');
+ expect(alert).toHaveAttribute('title', 'Error notification');
+ });
+
+ it('should render with minimal props (just type)', () => {
+ render( );
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(
+ screen.getByText('Oops! Something Went Wrong')
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Integration Tests
+ // ==========================================================================
+
+ describe('integration', () => {
+ it('should render complete quota error with all features', () => {
+ const customMessage =
+ 'Rate limit exceeded. Please wait before sending more requests.';
+
+ render(
+
+ );
+
+ // Title
+ expect(
+ screen.getByText('Service Temporarily Unavailable')
+ ).toBeInTheDocument();
+
+ // Custom message
+ expect(screen.getByText(customMessage)).toBeInTheDocument();
+
+ // Countdown
+ expect(screen.getByText(/45s/)).toBeInTheDocument();
+
+ // Buttons
+ expect(
+ screen.getByRole('button', { name: /please wait/i })
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Dismiss error')).toBeInTheDocument();
+
+ // Icons
+ const clockIcons = screen.getAllByTestId('clock-icon');
+ expect(clockIcons.length).toBeGreaterThan(0);
+ });
+
+ it('should render complete network error with all features', () => {
+ render(
+
+ );
+
+ // Title
+ expect(screen.getByText('Connection Lost')).toBeInTheDocument();
+
+ // Default message
+ expect(
+ screen.getByText(DEFAULT_MESSAGES.network)
+ ).toBeInTheDocument();
+
+ // Buttons (not disabled for network type)
+ expect(
+ screen.getByRole('button', { name: /try again/i })
+ ).not.toBeDisabled();
+
+ // Icon
+ expect(screen.getByTestId('wifi-off-icon')).toBeInTheDocument();
+ });
+
+ it('should work correctly through a complete user flow', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const mockRetry = vi.fn();
+ const mockDismiss = vi.fn();
+
+ render(
+
+ );
+
+ // Initially button is disabled
+ expect(
+ screen.getByRole('button', { name: /please wait/i })
+ ).toBeDisabled();
+
+ // Wait for countdown
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText(/2s/)).toBeInTheDocument();
+
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText(/1s/)).toBeInTheDocument();
+
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ // Now button should be enabled
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ expect(retryButton).not.toBeDisabled();
+
+ // Click retry
+ fireEvent.click(retryButton);
+ expect(mockRetry).toHaveBeenCalledTimes(1);
+
+ // Click dismiss
+ fireEvent.click(screen.getByLabelText('Dismiss error'));
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+ });
+});
diff --git a/frontend/src/components/chat/__tests__/provider-toggle.test.tsx b/frontend/src/components/chat/__tests__/provider-toggle.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d0274eafbd5c2c067ce5fe688b10dc963d8880df
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/provider-toggle.test.tsx
@@ -0,0 +1,1376 @@
+/**
+ * Unit Tests for ProviderToggle Component
+ *
+ * Comprehensive test coverage for the provider selection component.
+ * Tests rendering states, provider pills, selection behavior,
+ * cooldown timers, accessibility, and compact mode.
+ *
+ * @module components/chat/__tests__/provider-toggle.test
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
+import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ProviderToggle } from '../provider-toggle';
+import { useProviders, type ProviderStatus } from '@/hooks';
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+vi.mock('@/hooks', () => ({
+ useProviders: vi.fn(),
+}));
+
+// ============================================================================
+// Test Helpers
+// ============================================================================
+
+/**
+ * Create a mock provider status object.
+ *
+ * @param overrides - Properties to override in the default provider
+ * @returns Mock provider status
+ */
+function createMockProvider(
+ overrides: Partial = {}
+): ProviderStatus {
+ return {
+ id: 'gemini',
+ name: 'Gemini',
+ description: 'Gemini Flash / Gemma',
+ isAvailable: true,
+ cooldownSeconds: null,
+ totalRequestsRemaining: 10,
+ primaryModel: 'gemini-2.5-flash-lite',
+ allModels: ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash', 'gemma-3-27b-it'],
+ ...overrides,
+ };
+}
+
+/**
+ * Create default mock return value for useProviders hook.
+ *
+ * @param overrides - Properties to override in the default return value
+ * @returns Mock useProviders return value
+ */
+function createMockUseProvidersReturn(
+ overrides: Partial> = {}
+): ReturnType {
+ return {
+ providers: [],
+ selectedProvider: null,
+ selectProvider: vi.fn(),
+ isLoading: false,
+ error: null,
+ refresh: vi.fn(),
+ lastUpdated: new Date(),
+ ...overrides,
+ };
+}
+
+/**
+ * Set up the useProviders mock with the given return value.
+ *
+ * @param mockReturn - Mock return value for useProviders
+ */
+function setupMock(mockReturn: ReturnType): void {
+ (useProviders as Mock).mockReturnValue(mockReturn);
+}
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('ProviderToggle', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ // ==========================================================================
+ // Rendering States Tests
+ // ==========================================================================
+
+ describe('rendering states', () => {
+ it('should render loading skeleton when isLoading is true', () => {
+ setupMock(
+ createMockUseProvidersReturn({
+ isLoading: true,
+ providers: [],
+ })
+ );
+
+ render( );
+
+ // Should have skeleton elements with aria-label for loading
+ const loadingGroup = screen.getByRole('group', {
+ name: /loading provider options/i,
+ });
+ expect(loadingGroup).toBeInTheDocument();
+
+ // Should have 3 skeleton pills (Auto + 2 providers)
+ const skeletons = loadingGroup.querySelectorAll('.animate-pulse');
+ expect(skeletons).toHaveLength(3);
+ });
+
+ it('should render error state with refresh button when error and no providers', () => {
+ const mockRefresh = vi.fn();
+ setupMock(
+ createMockUseProvidersReturn({
+ isLoading: false,
+ error: 'Failed to load providers',
+ providers: [],
+ refresh: mockRefresh,
+ })
+ );
+
+ render( );
+
+ // Should show error alert
+ const alert = screen.getByRole('alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert).toHaveTextContent('Failed to load providers');
+
+ // Should have retry button
+ const retryButton = screen.getByRole('button', {
+ name: /retry loading providers/i,
+ });
+ expect(retryButton).toBeInTheDocument();
+ });
+
+ it('should render provider pills when providers are available', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ // Should have radiogroup role
+ const radiogroup = screen.getByRole('radiogroup', {
+ name: /select llm provider/i,
+ });
+ expect(radiogroup).toBeInTheDocument();
+
+ // Should have provider pills (Auto + 2 providers)
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(3);
+ });
+
+ it('should always render "Auto" option first', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const radios = screen.getAllByRole('radio');
+
+ // First radio should be Auto
+ expect(radios[0]).toHaveAccessibleName(/auto/i);
+ });
+
+ it('should show providers even when there is an error if providers exist', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ error: 'Refresh failed',
+ })
+ );
+
+ render( );
+
+ // Should show providers, not error
+ const radiogroup = screen.getByRole('radiogroup');
+ expect(radiogroup).toBeInTheDocument();
+
+ // Should not show error alert when providers exist
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Provider Pills Tests
+ // ==========================================================================
+
+ describe('provider pills', () => {
+ it('should display provider name', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('Auto')).toBeInTheDocument();
+ expect(screen.getByText('Gemini')).toBeInTheDocument();
+ expect(screen.getByText('Groq')).toBeInTheDocument();
+ });
+
+ it('should show green status indicator for available providers', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'gemini',
+ name: 'Gemini',
+ isAvailable: true,
+ cooldownSeconds: null,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Find the Gemini button and check for green indicator
+ const geminiButton = screen.getByRole('radio', { name: /gemini/i });
+ const indicator = geminiButton.querySelector('.bg-\\[var\\(--success\\)\\]');
+ expect(indicator).toBeInTheDocument();
+ });
+
+ it('should show red status indicator for providers in cooldown', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 45,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Find the Groq button and check for red indicator
+ const groqButton = screen.getByRole('radio', { name: /groq/i });
+ const indicator = groqButton.querySelector('.bg-\\[var\\(--error\\)\\]');
+ expect(indicator).toBeInTheDocument();
+ });
+
+ it('should show gray status indicator for unavailable providers without cooldown', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'deepseek',
+ name: 'DeepSeek',
+ isAvailable: false,
+ cooldownSeconds: null,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Find the DeepSeek button and check for gray indicator
+ const deepseekButton = screen.getByRole('radio', { name: /deepseek/i });
+ const indicator = deepseekButton.querySelector(
+ '.bg-\\[var\\(--foreground-muted\\)\\]'
+ );
+ expect(indicator).toBeInTheDocument();
+ });
+
+ it('should mark selected provider with aria-checked true', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'gemini',
+ })
+ );
+
+ render( );
+
+ const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
+ expect(geminiButton).toHaveAttribute('aria-checked', 'true');
+
+ const groqButton = screen.getByRole('radio', { name: /groq/i });
+ expect(groqButton).toHaveAttribute('aria-checked', 'false');
+
+ const autoButton = screen.getByRole('radio', { name: /auto/i });
+ expect(autoButton).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('should mark Auto as selected when selectedProvider is null', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: null,
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
+ expect(autoButton).toHaveAttribute('aria-checked', 'true');
+ });
+ });
+
+ // ==========================================================================
+ // Provider Selection Tests
+ // ==========================================================================
+
+ describe('provider selection', () => {
+ it('should call selectProvider when a pill is clicked', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const geminiButton = screen.getByRole('radio', { name: /gemini/i });
+ await user.click(geminiButton);
+
+ expect(mockSelectProvider).toHaveBeenCalledTimes(1);
+ expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
+ });
+
+ it('should update aria-checked attribute for selected provider', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'groq',
+ })
+ );
+
+ render( );
+
+ const groqButton = screen.getByRole('radio', { name: /groq.*selected/i });
+ expect(groqButton).toHaveAttribute('aria-checked', 'true');
+
+ const geminiButton = screen.getByRole('radio', { name: /gemini/i });
+ expect(geminiButton).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('should allow selecting Auto (null) option', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'gemini',
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto/i });
+ await user.click(autoButton);
+
+ expect(mockSelectProvider).toHaveBeenCalledWith(null);
+ });
+
+ it('should allow selecting unavailable providers (user choice)', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 60,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const groqButton = screen.getByRole('radio', { name: /groq/i });
+ await user.click(groqButton);
+
+ // Should still allow selection even if unavailable
+ expect(mockSelectProvider).toHaveBeenCalledWith('groq');
+ });
+ });
+
+ // ==========================================================================
+ // Cooldown Timer Tests
+ // ==========================================================================
+
+ describe('cooldown timer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should display cooldown time for providers with cooldownSeconds', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 45,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ // Should display the countdown
+ expect(screen.getByText('45s')).toBeInTheDocument();
+ });
+
+ it('should format time as "Xm Ys" for times with minutes and seconds', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 130, // 2m 10s
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('2m 10s')).toBeInTheDocument();
+ });
+
+ it('should format time as "Xm" for exact minutes', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 120, // 2m 0s
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('2m')).toBeInTheDocument();
+ });
+
+ it('should format time as "Xs" for times under a minute', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 30,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('30s')).toBeInTheDocument();
+ });
+
+ it('should decrement countdown every second', async () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 5,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('5s')).toBeInTheDocument();
+
+ // Advance by 1 second using act to wrap the state update
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText('4s')).toBeInTheDocument();
+
+ // Advance by another second
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText('3s')).toBeInTheDocument();
+ });
+
+ it('should stop countdown and hide when reaching 0', async () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 2,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('2s')).toBeInTheDocument();
+
+ // Advance to 1s
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText('1s')).toBeInTheDocument();
+
+ // Advance past 0
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ // Timer should be hidden
+ expect(screen.queryByText('0s')).not.toBeInTheDocument();
+ expect(screen.queryByText('1s')).not.toBeInTheDocument();
+ });
+
+ it('should not display timer when cooldownSeconds is null', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'gemini',
+ name: 'Gemini',
+ isAvailable: true,
+ cooldownSeconds: null,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Should not have any time elements in Gemini button
+ const geminiButton = screen.getByRole('radio', { name: /gemini/i });
+ expect(geminiButton.textContent).not.toMatch(/\d+s/);
+ expect(geminiButton.textContent).not.toMatch(/\d+m/);
+ });
+
+ it('should not display timer when cooldownSeconds is 0', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 0,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Should not display any countdown
+ const groqButton = screen.getByRole('radio', { name: /groq/i });
+ expect(groqButton.textContent).not.toMatch(/\d+s/);
+ });
+ });
+
+ // ==========================================================================
+ // Accessibility Tests
+ // ==========================================================================
+
+ describe('accessibility', () => {
+ it('should have radiogroup role on container', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const radiogroup = screen.getByRole('radiogroup', {
+ name: /select llm provider/i,
+ });
+ expect(radiogroup).toBeInTheDocument();
+ });
+
+ it('should have radio role on each pill', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(3); // Auto + 2 providers
+ });
+
+ it('should support keyboard navigation with arrow keys', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: null,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ // Focus the first (Auto) button
+ const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
+ autoButton.focus();
+
+ // Press ArrowRight to move to next option
+ await user.keyboard('{ArrowRight}');
+
+ expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
+ });
+
+ it('should support ArrowDown as alternative to ArrowRight', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: null,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
+ autoButton.focus();
+
+ await user.keyboard('{ArrowDown}');
+
+ expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
+ });
+
+ it('should support ArrowLeft/ArrowUp to move backwards', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'gemini',
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
+ geminiButton.focus();
+
+ // Press ArrowLeft to go back to Auto
+ await user.keyboard('{ArrowLeft}');
+
+ expect(mockSelectProvider).toHaveBeenCalledWith(null);
+ });
+
+ it('should support Home key to go to first option', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'groq',
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const groqButton = screen.getByRole('radio', { name: /groq.*selected/i });
+ groqButton.focus();
+
+ await user.keyboard('{Home}');
+
+ // Should select Auto (first option, id = null)
+ expect(mockSelectProvider).toHaveBeenCalledWith(null);
+ });
+
+ it('should support End key to go to last option', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: null,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
+ autoButton.focus();
+
+ await user.keyboard('{End}');
+
+ // Should select last option (groq)
+ expect(mockSelectProvider).toHaveBeenCalledWith('groq');
+ });
+
+ it('should use roving tabindex pattern', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'gemini',
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto/i });
+ const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
+ const groqButton = screen.getByRole('radio', { name: /groq/i });
+
+ // Only the selected option should have tabindex=0
+ expect(geminiButton).toHaveAttribute('tabindex', '0');
+ expect(autoButton).toHaveAttribute('tabindex', '-1');
+ expect(groqButton).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('should wrap around when navigating past the end', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'gemini',
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
+ geminiButton.focus();
+
+ // Press ArrowRight to wrap to Auto (first option)
+ await user.keyboard('{ArrowRight}');
+
+ expect(mockSelectProvider).toHaveBeenCalledWith(null);
+ });
+
+ it('should wrap around when navigating before the start', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: null,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
+ autoButton.focus();
+
+ // Press ArrowLeft to wrap to last option (gemini)
+ await user.keyboard('{ArrowLeft}');
+
+ expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
+ });
+
+ it('should have proper aria-label for unavailable providers', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: null,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const groqButton = screen.getByRole('radio', { name: /groq.*unavailable/i });
+ expect(groqButton).toBeInTheDocument();
+ });
+
+ it('should have proper aria-label for providers in cooldown', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 45,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const groqButton = screen.getByRole('radio', {
+ name: /groq.*cooling down/i,
+ });
+ expect(groqButton).toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Refresh Button Tests
+ // ==========================================================================
+
+ describe('refresh button', () => {
+ it('should call refresh when refresh button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockRefresh = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ refresh: mockRefresh,
+ })
+ );
+
+ render( );
+
+ const refreshButton = screen.getByRole('button', {
+ name: /refresh provider status/i,
+ });
+ await user.click(refreshButton);
+
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('should be accessible with proper aria-label', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const refreshButton = screen.getByRole('button', {
+ name: /refresh provider status/i,
+ });
+ expect(refreshButton).toHaveAttribute('title', 'Refresh provider status');
+ });
+
+ it('should call refresh in error state', async () => {
+ const user = userEvent.setup();
+ const mockRefresh = vi.fn();
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: [],
+ error: 'Failed to load',
+ refresh: mockRefresh,
+ })
+ );
+
+ render( );
+
+ const retryButton = screen.getByRole('button', {
+ name: /retry loading providers/i,
+ });
+ await user.click(retryButton);
+
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ==========================================================================
+ // Compact Mode Tests
+ // ==========================================================================
+
+ describe('compact mode', () => {
+ it('should render with smaller sizes when compact prop is true', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Check that compact classes are applied
+ const radiogroup = screen.getByRole('radiogroup');
+
+ // Container should have smaller gap
+ expect(radiogroup).toHaveClass('gap-1.5');
+ });
+
+ it('should render smaller refresh button in compact mode', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const refreshButton = screen.getByRole('button', {
+ name: /refresh provider status/i,
+ });
+
+ // Compact refresh button should have smaller dimensions
+ expect(refreshButton).toHaveClass('h-6', 'w-6');
+ });
+
+ it('should render normal size without compact prop', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const refreshButton = screen.getByRole('button', {
+ name: /refresh provider status/i,
+ });
+
+ // Normal refresh button should have larger dimensions
+ expect(refreshButton).toHaveClass('h-7', 'w-7');
+ });
+
+ it('should render smaller skeleton in compact mode when loading', () => {
+ setupMock(
+ createMockUseProvidersReturn({
+ isLoading: true,
+ providers: [],
+ })
+ );
+
+ const { container } = render( );
+
+ const skeletons = container.querySelectorAll('.animate-pulse');
+
+ // Check that skeletons have compact height
+ skeletons.forEach((skeleton) => {
+ expect(skeleton).toHaveClass('h-7');
+ });
+ });
+
+ it('should render smaller error state in compact mode', () => {
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: [],
+ error: 'Error message',
+ })
+ );
+
+ render( );
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveClass('text-xs');
+ });
+
+ it('should apply custom className', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const radiogroup = screen.getByRole('radiogroup');
+ expect(radiogroup).toHaveClass('custom-class', 'mt-4');
+ });
+ });
+
+ // ==========================================================================
+ // Edge Cases Tests
+ // ==========================================================================
+
+ describe('edge cases', () => {
+ it('should handle empty providers array', () => {
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: [],
+ isLoading: false,
+ error: null,
+ })
+ );
+
+ render( );
+
+ // Should still render radiogroup with just Auto option
+ const radiogroup = screen.getByRole('radiogroup');
+ expect(radiogroup).toBeInTheDocument();
+
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(1); // Just Auto
+ });
+
+ it('should handle provider with very long name', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'long-name-provider',
+ name: 'Very Long Provider Name That Might Overflow',
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ expect(
+ screen.getByText('Very Long Provider Name That Might Overflow')
+ ).toBeInTheDocument();
+ });
+
+ it('should handle multiple providers with mixed availability', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'gemini',
+ name: 'Gemini',
+ isAvailable: true,
+ }),
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ isAvailable: false,
+ cooldownSeconds: 30,
+ }),
+ createMockProvider({
+ id: 'deepseek',
+ name: 'DeepSeek',
+ isAvailable: false,
+ cooldownSeconds: null,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ render( );
+
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(4); // Auto + 3 providers
+
+ // Each should render correctly
+ expect(screen.getByText('Gemini')).toBeInTheDocument();
+ expect(screen.getByText('Groq')).toBeInTheDocument();
+ expect(screen.getByText('DeepSeek')).toBeInTheDocument();
+ expect(screen.getByText('30s')).toBeInTheDocument(); // Groq cooldown
+ });
+
+ it('should handle very long error message with truncation', () => {
+ const longError =
+ 'This is a very long error message that should be truncated to prevent layout issues in the UI when displaying error states';
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: [],
+ error: longError,
+ })
+ );
+
+ render( );
+
+ const errorSpan = screen.getByText(longError);
+ expect(errorSpan).toHaveClass('truncate');
+ expect(errorSpan).toHaveAttribute('title', longError);
+ });
+
+ it('should maintain focus after keyboard navigation', async () => {
+ const user = userEvent.setup();
+ const mockSelectProvider = vi.fn();
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ createMockProvider({ id: 'groq', name: 'Groq' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: null,
+ selectProvider: mockSelectProvider,
+ })
+ );
+
+ render( );
+
+ const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
+ autoButton.focus();
+
+ // Navigate to Gemini
+ await user.keyboard('{ArrowRight}');
+
+ // selectProvider should have been called
+ expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
+ });
+ });
+
+ // ==========================================================================
+ // Integration Tests
+ // ==========================================================================
+
+ describe('integration', () => {
+ it('should work correctly with a complete provider list', () => {
+ const mockProviders = [
+ createMockProvider({
+ id: 'gemini',
+ name: 'Gemini',
+ description: 'Google Gemini Pro',
+ isAvailable: true,
+ cooldownSeconds: null,
+ totalRequestsRemaining: 10,
+ }),
+ createMockProvider({
+ id: 'groq',
+ name: 'Groq',
+ description: 'Groq LLM',
+ isAvailable: false,
+ cooldownSeconds: 45,
+ totalRequestsRemaining: 0,
+ }),
+ createMockProvider({
+ id: 'deepseek',
+ name: 'DeepSeek',
+ description: 'DeepSeek Chat',
+ isAvailable: true,
+ cooldownSeconds: null,
+ totalRequestsRemaining: 5,
+ }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ selectedProvider: 'gemini',
+ })
+ );
+
+ render( );
+
+ // All providers should be rendered
+ expect(screen.getByText('Auto')).toBeInTheDocument();
+ expect(screen.getByText('Gemini')).toBeInTheDocument();
+ expect(screen.getByText('Groq')).toBeInTheDocument();
+ expect(screen.getByText('DeepSeek')).toBeInTheDocument();
+
+ // Cooldown should be shown
+ expect(screen.getByText('45s')).toBeInTheDocument();
+
+ // Selection should be correct
+ const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
+ expect(geminiButton).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('should render all elements with correct structure', () => {
+ const mockProviders = [
+ createMockProvider({ id: 'gemini', name: 'Gemini' }),
+ ];
+
+ setupMock(
+ createMockUseProvidersReturn({
+ providers: mockProviders,
+ })
+ );
+
+ const { container } = render( );
+
+ // Check structure
+ const radiogroup = screen.getByRole('radiogroup');
+ expect(radiogroup).toBeInTheDocument();
+
+ // Radio buttons should be inside the radiogroup
+ const radios = within(radiogroup).getAllByRole('radio');
+ expect(radios).toHaveLength(2);
+
+ // Refresh button should be present
+ const refreshButton = screen.getByRole('button', {
+ name: /refresh provider status/i,
+ });
+ expect(refreshButton).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/chat/__tests__/source-card.test.tsx b/frontend/src/components/chat/__tests__/source-card.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..52ae8f9cfe17bfdec1a572be1b7d2f9025517290
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/source-card.test.tsx
@@ -0,0 +1,805 @@
+/**
+ * Unit Tests for SourceCard Component
+ *
+ * Comprehensive test coverage for the source citation card component.
+ * Tests rendering, expand/collapse functionality, accessibility features,
+ * and edge cases for text truncation and special characters.
+ *
+ * @module components/chat/__tests__/source-card.test
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SourceCard } from '../source-card';
+import type { Source } from '@/types';
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+/**
+ * Mock lucide-react icons for faster tests and to avoid lazy loading issues.
+ * Each icon is replaced with a simple span containing a test ID.
+ */
+vi.mock('lucide-react', () => ({
+ FileText: ({ className }: { className?: string }) => (
+
+ ),
+ ChevronDown: ({ className }: { className?: string }) => (
+
+ ),
+ ChevronUp: ({ className }: { className?: string }) => (
+
+ ),
+}));
+
+// ============================================================================
+// Test Fixtures
+// ============================================================================
+
+/**
+ * Create a mock source object for testing.
+ *
+ * @param overrides - Properties to override in the default source
+ * @returns Mock source object with sensible defaults
+ */
+function createMockSource(overrides: Partial = {}): Source {
+ return {
+ id: 'test-source-1',
+ headingPath: 'Chapter 1 > Section 2 > Subsection A',
+ page: 42,
+ text: 'This is the source text content from the pythermalcomfort documentation.',
+ score: 0.85,
+ ...overrides,
+ };
+}
+
+/**
+ * Long text fixture for testing truncation behavior.
+ * Contains more than 150 characters to trigger truncation.
+ */
+const LONG_TEXT =
+ 'This is a very long text that exceeds the default truncation length of 150 characters. ' +
+ 'It contains multiple sentences to test the word-boundary truncation behavior. ' +
+ 'The truncation should happen at a word boundary to avoid cutting words in the middle. ' +
+ 'This ensures a clean user experience when displaying source excerpts.';
+
+/**
+ * Short text fixture for testing non-truncation behavior.
+ * Contains fewer than 150 characters.
+ */
+const SHORT_TEXT = 'This is short text that fits within the truncation limit.';
+
+/**
+ * Text with special characters for edge case testing.
+ */
+const SPECIAL_CHARS_TEXT =
+ 'Temperature formula: T = (a + b) / 2 where a > 0 & b < 100. Use calc() for computation.';
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('SourceCard', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ // ==========================================================================
+ // Rendering Tests
+ // ==========================================================================
+
+ describe('rendering', () => {
+ it('should render heading path correctly', () => {
+ // Test that the heading path is displayed with proper hierarchy
+ const source = createMockSource({
+ headingPath: 'Introduction > Thermal Comfort > PMV Model',
+ });
+
+ render( );
+
+ expect(
+ screen.getByText('Introduction > Thermal Comfort > PMV Model')
+ ).toBeInTheDocument();
+ });
+
+ it('should render page number badge', () => {
+ // Test that page number is shown in a badge format
+ const source = createMockSource({ page: 78 });
+
+ render( );
+
+ // Should show "Page X" format
+ expect(screen.getByText('Page 78')).toBeInTheDocument();
+ });
+
+ it('should render page badge with correct aria-label', () => {
+ // Test accessibility of page badge
+ const source = createMockSource({ page: 123 });
+
+ render( );
+
+ const pageBadge = screen.getByLabelText('Page 123');
+ expect(pageBadge).toBeInTheDocument();
+ });
+
+ it('should render source text (truncated when long)', () => {
+ // Test that long text is truncated by default
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ // Should show truncated text with ellipsis
+ const textElement = screen.getByText(/This is a very long text/);
+ expect(textElement).toBeInTheDocument();
+ // Full text should not be visible
+ expect(screen.queryByText(LONG_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('should render full text when short enough', () => {
+ // Test that short text is not truncated
+ const source = createMockSource({ text: SHORT_TEXT });
+
+ render( );
+
+ expect(screen.getByText(SHORT_TEXT)).toBeInTheDocument();
+ });
+
+ it('should render score badge when showScore is true', () => {
+ // Test score badge rendering with showScore prop
+ const source = createMockSource({ score: 0.85 });
+
+ render( );
+
+ // Score should be displayed as percentage
+ expect(screen.getByText('85%')).toBeInTheDocument();
+ });
+
+ it('should render score badge with correct aria-label', () => {
+ // Test accessibility of score badge
+ const source = createMockSource({ score: 0.92 });
+
+ render( );
+
+ expect(
+ screen.getByLabelText('Relevance score: 92 percent')
+ ).toBeInTheDocument();
+ });
+
+ it('should not render score badge when showScore is false', () => {
+ // Test that score is hidden by default
+ const source = createMockSource({ score: 0.75 });
+
+ render( );
+
+ expect(screen.queryByText('75%')).not.toBeInTheDocument();
+ });
+
+ it('should not render score badge when showScore is true but score is undefined', () => {
+ // Test handling of undefined score
+ const source = createMockSource({ score: undefined });
+
+ render( );
+
+ // No percentage should be rendered
+ expect(screen.queryByText(/%/)).not.toBeInTheDocument();
+ });
+
+ it('should not render score badge by default (showScore defaults to false)', () => {
+ // Test default behavior
+ const source = createMockSource({ score: 0.88 });
+
+ render( );
+
+ expect(screen.queryByText('88%')).not.toBeInTheDocument();
+ });
+
+ it('should render file icon in header', () => {
+ // Test that FileText icon is present
+ const source = createMockSource();
+
+ render( );
+
+ expect(screen.getByTestId('file-icon')).toBeInTheDocument();
+ });
+
+ it('should apply high score color styling (>= 80%)', () => {
+ // Test that high scores get success color
+ const source = createMockSource({ score: 0.85 });
+
+ render( );
+
+ const scoreBadge = screen.getByLabelText('Relevance score: 85 percent');
+ expect(scoreBadge.className).toMatch(/success/);
+ });
+
+ it('should apply medium score color styling (60-79%)', () => {
+ // Test that medium scores get primary color
+ const source = createMockSource({ score: 0.65 });
+
+ render( );
+
+ const scoreBadge = screen.getByLabelText('Relevance score: 65 percent');
+ expect(scoreBadge.className).toMatch(/primary/);
+ });
+
+ it('should apply low score color styling (< 60%)', () => {
+ // Test that low scores get muted color
+ const source = createMockSource({ score: 0.45 });
+
+ render( );
+
+ const scoreBadge = screen.getByLabelText('Relevance score: 45 percent');
+ expect(scoreBadge.className).toMatch(/foreground-muted|background-tertiary/);
+ });
+ });
+
+ // ==========================================================================
+ // Expand/Collapse Tests
+ // ==========================================================================
+
+ describe('expand/collapse', () => {
+ it('should start collapsed by default', () => {
+ // Test default collapsed state
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ // Should show "Show more" button indicating collapsed state
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+
+ it('should start expanded when defaultExpanded is true', () => {
+ // Test defaultExpanded prop
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ // Should show "Show less" button indicating expanded state
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+ // Full text should be visible
+ expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
+ });
+
+ it('should toggle expand/collapse on button click', async () => {
+ // Test click interaction
+ const user = userEvent.setup();
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ // Initially collapsed
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+
+ // Click to expand
+ await user.click(screen.getByText('Show more'));
+
+ // Should now be expanded
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+ expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
+
+ // Click to collapse
+ await user.click(screen.getByText('Show less'));
+
+ // Should be collapsed again
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+
+ it('should show full text when expanded', () => {
+ // Test that full text is visible when expanded
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
+ });
+
+ it('should show truncated text when collapsed', () => {
+ // Test truncation in collapsed state
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ // Full text should not be present
+ expect(screen.queryByText(LONG_TEXT)).not.toBeInTheDocument();
+ // Truncated version should be present (with ellipsis)
+ expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
+ });
+
+ it('should change button text from "Show more" to "Show less"', async () => {
+ // Test button label changes
+ const user = userEvent.setup();
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ expect(screen.queryByText('Show less')).not.toBeInTheDocument();
+
+ await user.click(screen.getByText('Show more'));
+
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+ });
+
+ it('should show ChevronDown icon when collapsed', () => {
+ // Test chevron icon in collapsed state
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ expect(screen.getByTestId('chevron-down-icon')).toBeInTheDocument();
+ });
+
+ it('should show ChevronUp icon when expanded', () => {
+ // Test chevron icon in expanded state
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ expect(screen.getByTestId('chevron-up-icon')).toBeInTheDocument();
+ });
+
+ it('should not show expand button for short text', () => {
+ // Test that button is hidden when not needed
+ const source = createMockSource({ text: SHORT_TEXT });
+
+ render( );
+
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+ expect(screen.queryByText('Show less')).not.toBeInTheDocument();
+ });
+
+ it('should respect custom truncateLength prop', () => {
+ // Test custom truncation length
+ const source = createMockSource({ text: SHORT_TEXT });
+
+ // Set truncateLength shorter than the text
+ render( );
+
+ // Should now show expand button since text exceeds custom limit
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Accessibility Tests
+ // ==========================================================================
+
+ describe('accessibility', () => {
+ it('should have correct aria-expanded attribute when collapsed', () => {
+ // Test aria-expanded state for collapsed
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('should have correct aria-expanded attribute when expanded', () => {
+ // Test aria-expanded state for expanded
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should update aria-expanded attribute on toggle', async () => {
+ // Test aria-expanded updates dynamically
+ const user = userEvent.setup();
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ await user.click(button);
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should have aria-controls linking to text region', () => {
+ // Test aria-controls relationship
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ const controlsId = button.getAttribute('aria-controls');
+
+ expect(controlsId).toBe('source-text-region');
+ expect(document.getElementById('source-text-region')).toBeInTheDocument();
+ });
+
+ it('should respond to Enter key for toggle', async () => {
+ // Test keyboard navigation with Enter
+ const user = userEvent.setup();
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ button.focus();
+
+ // Press Enter to expand
+ await user.keyboard('{Enter}');
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should respond to Space key for toggle', async () => {
+ // Test keyboard navigation with Space
+ const user = userEvent.setup();
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ button.focus();
+
+ // Press Space to expand
+ await user.keyboard(' ');
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should have proper semantic structure with article element', () => {
+ // Test that card uses article element for semantic grouping
+ const source = createMockSource();
+
+ render( );
+
+ const article = screen.getByRole('article');
+ expect(article).toBeInTheDocument();
+ });
+
+ it('should have header element for card header content', () => {
+ // Test semantic header structure
+ const source = createMockSource();
+
+ const { container } = render( );
+
+ const header = container.querySelector('header');
+ expect(header).toBeInTheDocument();
+ });
+
+ it('should have descriptive aria-label on article element', () => {
+ // Test article has accessible name
+ const source = createMockSource({
+ headingPath: 'Thermal Comfort > PMV',
+ page: 15,
+ });
+
+ render( );
+
+ const article = screen.getByRole('article');
+ expect(article).toHaveAttribute(
+ 'aria-label',
+ 'Source from Thermal Comfort > PMV, page 15'
+ );
+ });
+
+ it('should be keyboard focusable on expand button', () => {
+ // Test focus is possible
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ button.focus();
+
+ expect(document.activeElement).toBe(button);
+ });
+
+ it('should have visible focus styles', () => {
+ // Test that focus ring classes are present
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button.className).toMatch(/focus-visible:ring/);
+ });
+ });
+
+ // ==========================================================================
+ // Edge Cases Tests
+ // ==========================================================================
+
+ describe('edge cases', () => {
+ it('should handle very long text correctly', () => {
+ // Test with extremely long text
+ const veryLongText = 'A'.repeat(1000);
+ const source = createMockSource({ text: veryLongText });
+
+ render( );
+
+ // Should truncate and show expand button
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ // Full text should not be visible
+ expect(screen.queryByText(veryLongText)).not.toBeInTheDocument();
+ });
+
+ it('should handle short text (no truncation needed)', () => {
+ // Test that short text shows in full without button
+ const source = createMockSource({ text: 'Short.' });
+
+ render( );
+
+ expect(screen.getByText('Short.')).toBeInTheDocument();
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+ });
+
+ it('should handle text at exact truncation boundary', () => {
+ // Test text that is exactly at the truncation limit
+ const exactLengthText = 'X'.repeat(150);
+ const source = createMockSource({ text: exactLengthText });
+
+ render( );
+
+ // Should show full text since it's exactly at the limit
+ expect(screen.getByText(exactLengthText)).toBeInTheDocument();
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+ });
+
+ it('should handle text just over truncation boundary', () => {
+ // Test text that is just over the truncation limit
+ const overLimitText = 'X'.repeat(151);
+ const source = createMockSource({ text: overLimitText });
+
+ render( );
+
+ // Should be truncated
+ expect(screen.queryByText(overLimitText)).not.toBeInTheDocument();
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+
+ it('should handle special characters in heading path', () => {
+ // Test special characters in heading path
+ const source = createMockSource({
+ headingPath: 'Chapter <1> & Section "2" > Sub\'section',
+ });
+
+ render( );
+
+ expect(
+ screen.getByText('Chapter <1> & Section "2" > Sub\'section')
+ ).toBeInTheDocument();
+ });
+
+ it('should handle special characters in text content', () => {
+ // Test special characters in text
+ const source = createMockSource({ text: SPECIAL_CHARS_TEXT });
+
+ render( );
+
+ expect(screen.getByText(SPECIAL_CHARS_TEXT)).toBeInTheDocument();
+ });
+
+ it('should handle empty heading path', () => {
+ // Test empty heading path edge case
+ const source = createMockSource({ headingPath: '' });
+
+ render( );
+
+ // Should still render without crashing
+ expect(screen.getByRole('article')).toBeInTheDocument();
+ });
+
+ it('should handle zero score correctly', () => {
+ // Test zero score display
+ const source = createMockSource({ score: 0 });
+
+ render( );
+
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ });
+
+ it('should handle perfect score (1.0) correctly', () => {
+ // Test maximum score display
+ const source = createMockSource({ score: 1 });
+
+ render( );
+
+ expect(screen.getByText('100%')).toBeInTheDocument();
+ });
+
+ it('should round score to nearest integer', () => {
+ // Test score rounding
+ const source = createMockSource({ score: 0.876 });
+
+ render( );
+
+ expect(screen.getByText('88%')).toBeInTheDocument();
+ });
+
+ it('should handle page number 0', () => {
+ // Test edge case of page 0
+ const source = createMockSource({ page: 0 });
+
+ render( );
+
+ expect(screen.getByText('Page 0')).toBeInTheDocument();
+ });
+
+ it('should handle very large page numbers', () => {
+ // Test large page number display
+ const source = createMockSource({ page: 99999 });
+
+ render( );
+
+ expect(screen.getByText('Page 99999')).toBeInTheDocument();
+ });
+
+ it('should apply custom className to article element', () => {
+ // Test className prop is passed through
+ const source = createMockSource();
+
+ render( );
+
+ const article = screen.getByRole('article');
+ expect(article).toHaveClass('custom-class', 'mt-4');
+ });
+
+ it('should forward ref to article element', () => {
+ // Test ref forwarding
+ const source = createMockSource();
+ const ref = vi.fn();
+
+ render( );
+
+ expect(ref).toHaveBeenCalled();
+ expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
+ });
+
+ it('should preserve whitespace in text content', () => {
+ // Test that whitespace is preserved
+ const textWithSpaces = 'Line 1\n\nLine 2\n Indented line';
+ const source = createMockSource({ text: textWithSpaces });
+
+ const { container } = render(
+
+ );
+
+ // Check whitespace-pre-wrap class is applied
+ const textElement = container.querySelector('p');
+ expect(textElement).toHaveClass('whitespace-pre-wrap');
+ });
+
+ it('should handle Unicode characters correctly', () => {
+ // Test Unicode text content
+ const unicodeText =
+ 'Temperature: 25\u00B0C, PMV: \u00B10.5, Humidity: 50%';
+ const source = createMockSource({ text: unicodeText });
+
+ render( );
+
+ expect(screen.getByText(unicodeText)).toBeInTheDocument();
+ });
+
+ it('should handle multi-byte characters in heading path', () => {
+ // Test international characters
+ const source = createMockSource({
+ headingPath: '\u65E5\u672C\u8A9E > \u7B2C1\u7AE0',
+ });
+
+ render( );
+
+ expect(
+ screen.getByText('\u65E5\u672C\u8A9E > \u7B2C1\u7AE0')
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Props Tests
+ // ==========================================================================
+
+ describe('props', () => {
+ it('should use default truncateLength of 150', () => {
+ // Test default truncation behavior
+ const text149 = 'A'.repeat(149);
+ const text151 = 'A'.repeat(151);
+
+ const source149 = createMockSource({ text: text149 });
+ const source151 = createMockSource({ text: text151 });
+
+ const { rerender } = render( );
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+
+ it('should accept custom truncateLength', () => {
+ // Test custom truncation length
+ const text = 'A'.repeat(100);
+ const source = createMockSource({ text });
+
+ render( );
+
+ // Should be truncated with custom length
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ });
+
+ it('should pass additional HTML attributes to article', () => {
+ // Test that other props are spread to article
+ const source = createMockSource();
+
+ render(
+
+ );
+
+ const article = screen.getByTestId('custom-card');
+ expect(article).toHaveAttribute('title', 'Test');
+ });
+ });
+
+ // ==========================================================================
+ // Integration Tests
+ // ==========================================================================
+
+ describe('integration', () => {
+ it('should render complete source card with all elements', () => {
+ // Test full rendering with all features
+ const source = createMockSource({
+ id: 'complete-source',
+ headingPath: 'Documentation > API Reference > Functions',
+ page: 42,
+ text: LONG_TEXT,
+ score: 0.92,
+ });
+
+ render( );
+
+ // All elements should be present
+ expect(
+ screen.getByText('Documentation > API Reference > Functions')
+ ).toBeInTheDocument();
+ expect(screen.getByText('Page 42')).toBeInTheDocument();
+ expect(screen.getByText('92%')).toBeInTheDocument();
+ expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+ expect(screen.getByTestId('file-icon')).toBeInTheDocument();
+ expect(screen.getByTestId('chevron-up-icon')).toBeInTheDocument();
+ });
+
+ it('should maintain state across multiple expand/collapse cycles', async () => {
+ // Test state persistence
+ const user = userEvent.setup();
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ // Initial state
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+
+ // Cycle through states
+ for (let i = 0; i < 3; i++) {
+ await user.click(screen.getByRole('button'));
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button'));
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+ }
+ });
+
+ it('should work with fireEvent as alternative to userEvent', () => {
+ // Test with fireEvent for synchronous testing
+ const source = createMockSource({ text: LONG_TEXT });
+
+ render( );
+
+ expect(screen.getByText('Show more')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/chat/__tests__/source-citations.test.tsx b/frontend/src/components/chat/__tests__/source-citations.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9fc65d0c224e9ae5a21ef9c041a629dff816918e
--- /dev/null
+++ b/frontend/src/components/chat/__tests__/source-citations.test.tsx
@@ -0,0 +1,852 @@
+/**
+ * Unit Tests for SourceCitations Component
+ *
+ * Comprehensive test coverage for the collapsible source citations container.
+ * Tests rendering states, expand/collapse animations, accessibility features,
+ * and integration with SourceCard components.
+ *
+ * @module components/chat/__tests__/source-citations.test
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SourceCitations } from '../source-citations';
+import type { Source } from '@/types';
+
+// ============================================================================
+// Test Fixtures
+// ============================================================================
+
+/**
+ * Create a mock source object for testing.
+ *
+ * @param overrides - Properties to override in the default source
+ * @returns Mock source object with sensible defaults
+ */
+function createMockSource(overrides: Partial = {}): Source {
+ return {
+ id: `source-${Math.random().toString(36).substring(7)}`,
+ headingPath: 'Chapter 1 > Section 2',
+ page: 10,
+ text: 'Sample source text for testing purposes.',
+ score: 0.8,
+ ...overrides,
+ };
+}
+
+/**
+ * Create an array of mock sources for testing.
+ *
+ * @param count - Number of sources to create
+ * @returns Array of mock source objects
+ */
+function createMockSources(count: number): Source[] {
+ return Array.from({ length: count }, (_, index) =>
+ createMockSource({
+ id: `source-${index + 1}`,
+ headingPath: `Chapter ${index + 1} > Section ${index + 1}`,
+ page: (index + 1) * 10,
+ text: `This is the text content for source ${index + 1}.`,
+ score: 0.9 - index * 0.1,
+ })
+ );
+}
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('SourceCitations', () => {
+ // ==========================================================================
+ // Rendering Tests
+ // ==========================================================================
+
+ describe('rendering', () => {
+ it('should render correct source count', () => {
+ // Test that source count is displayed correctly
+ const sources = createMockSources(5);
+
+ render( );
+
+ expect(screen.getByText('5 Sources')).toBeInTheDocument();
+ });
+
+ it('should render singular "Source" when count is 1', () => {
+ // Test singular grammar
+ const sources = createMockSources(1);
+
+ render( );
+
+ expect(screen.getByText('1 Source')).toBeInTheDocument();
+ });
+
+ it('should render plural "Sources" when count > 1', () => {
+ // Test plural grammar
+ const sources = createMockSources(2);
+
+ render( );
+
+ expect(screen.getByText('2 Sources')).toBeInTheDocument();
+ });
+
+ it('should render plural "Sources" for large count', () => {
+ // Test plural grammar with larger numbers
+ const sources = createMockSources(10);
+
+ render( );
+
+ expect(screen.getByText('10 Sources')).toBeInTheDocument();
+ });
+
+ it('should not render when sources array is empty', () => {
+ // Test that component returns null for empty sources
+ const { container } = render( );
+
+ // Container should be empty (only the root div from render)
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should not render when sources is an empty array', () => {
+ // Explicitly test empty array
+ const sources: Source[] = [];
+
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render all SourceCard components when expanded', () => {
+ // Test that all source cards are rendered
+ const sources = createMockSources(3);
+
+ render( );
+
+ // Each source should have its heading path rendered
+ expect(screen.getByText('Chapter 1 > Section 1')).toBeInTheDocument();
+ expect(screen.getByText('Chapter 2 > Section 2')).toBeInTheDocument();
+ expect(screen.getByText('Chapter 3 > Section 3')).toBeInTheDocument();
+ });
+
+ it('should render toggle button in header', () => {
+ // Test toggle button presence
+ const sources = createMockSources(1);
+
+ render( );
+
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('should render source count in toggle button', () => {
+ // Test source count in button
+ const sources = createMockSources(3);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveTextContent('3 Sources');
+ });
+ });
+
+ // ==========================================================================
+ // Expand/Collapse Tests
+ // ==========================================================================
+
+ describe('expand/collapse', () => {
+ it('should start collapsed by default', () => {
+ // Test default collapsed state
+ const sources = createMockSources(3);
+
+ const { container } = render( );
+
+ // Content region should be hidden (use container query since aria-hidden elements are not accessible)
+ const region = container.querySelector('[role="region"]');
+ expect(region).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should start expanded when defaultExpanded is true', () => {
+ // Test defaultExpanded prop
+ const sources = createMockSources(3);
+
+ render( );
+
+ // Content region should be visible (accessible when expanded)
+ const region = screen.getByRole('region');
+ expect(region).toHaveAttribute('aria-hidden', 'false');
+ });
+
+ it('should toggle on button click', async () => {
+ // Test click interaction
+ const user = userEvent.setup();
+ const sources = createMockSources(2);
+
+ const { container } = render( );
+
+ const button = screen.getByRole('button');
+ const region = container.querySelector('[role="region"]');
+
+ // Initially collapsed
+ expect(region).toHaveAttribute('aria-hidden', 'true');
+
+ // Click to expand
+ await user.click(button);
+ expect(region).toHaveAttribute('aria-hidden', 'false');
+
+ // Click to collapse
+ await user.click(button);
+ expect(region).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should apply rotation class to chevron when expanded', () => {
+ // Test chevron rotation class - check the button contains rotated element
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ // The rotated element should have rotate-180 class when expanded
+ const rotatedElement = button.querySelector('.rotate-180');
+ expect(rotatedElement).toBeInTheDocument();
+ });
+
+ it('should not have rotation class when collapsed', () => {
+ // Test chevron not rotated when collapsed
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ // Should not have rotate-180 when collapsed
+ const rotatedElement = button.querySelector('.rotate-180');
+ expect(rotatedElement).toBeNull();
+ });
+
+ it('should apply grid-rows-[0fr] when collapsed', () => {
+ // Test CSS grid animation class for collapsed state
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const region = container.querySelector('[role="region"]');
+ expect(region?.className).toMatch(/grid-rows-\[0fr\]/);
+ });
+
+ it('should apply grid-rows-[1fr] when expanded', () => {
+ // Test CSS grid animation class for expanded state
+ const sources = createMockSources(1);
+
+ render( );
+
+ const region = screen.getByRole('region');
+ expect(region.className).toMatch(/grid-rows-\[1fr\]/);
+ });
+
+ it('should hide content when collapsed', () => {
+ // Test that content is hidden in collapsed state
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const region = container.querySelector('[role="region"]');
+ expect(region).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should show content when expanded', () => {
+ // Test that content is visible in expanded state
+ const sources = createMockSources(1);
+
+ render( );
+
+ const region = screen.getByRole('region');
+ expect(region).toHaveAttribute('aria-hidden', 'false');
+ // Source text should be visible
+ expect(
+ screen.getByText('This is the text content for source 1.')
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // Accessibility Tests
+ // ==========================================================================
+
+ describe('accessibility', () => {
+ it('should have correct aria-expanded attribute when collapsed', () => {
+ // Test aria-expanded in collapsed state
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('should have correct aria-expanded attribute when expanded', () => {
+ // Test aria-expanded in expanded state
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should update aria-expanded on toggle', async () => {
+ // Test dynamic aria-expanded updates
+ const user = userEvent.setup();
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ await user.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+
+ await user.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('should have aria-controls linking to content region', () => {
+ // Test aria-controls relationship
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const button = screen.getByRole('button');
+ const region = container.querySelector('[role="region"]');
+
+ const controlsId = button.getAttribute('aria-controls');
+ expect(controlsId).toBeTruthy();
+ expect(region).toHaveAttribute('id', controlsId);
+ });
+
+ it('should have content region with role="region"', () => {
+ // Test region role on content
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const region = container.querySelector('[role="region"]');
+ expect(region).toBeInTheDocument();
+ });
+
+ it('should have region with aria-labelledby pointing to button', () => {
+ // Test aria-labelledby relationship
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const button = screen.getByRole('button');
+ const region = container.querySelector('[role="region"]');
+
+ const buttonId = button.getAttribute('id');
+ expect(buttonId).toBeTruthy();
+ expect(region).toHaveAttribute('aria-labelledby', buttonId);
+ });
+
+ it('should have descriptive aria-label on toggle button', () => {
+ // Test button has accessible name
+ const sources = createMockSources(3);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ const ariaLabel = button.getAttribute('aria-label');
+
+ expect(ariaLabel).toMatch(/3 Sources/);
+ expect(ariaLabel).toMatch(/Expand/);
+ });
+
+ it('should update aria-label when expanded', () => {
+ // Test aria-label changes based on state
+ const sources = createMockSources(3);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ const ariaLabel = button.getAttribute('aria-label');
+
+ expect(ariaLabel).toMatch(/3 Sources/);
+ expect(ariaLabel).toMatch(/Collapse/);
+ });
+
+ it('should respond to Enter key for toggle', async () => {
+ // Test keyboard navigation with Enter
+ const user = userEvent.setup();
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ button.focus();
+
+ await user.keyboard('{Enter}');
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should respond to Space key for toggle', async () => {
+ // Test keyboard navigation with Space
+ const user = userEvent.setup();
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ button.focus();
+
+ await user.keyboard(' ');
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should be keyboard focusable on toggle button', () => {
+ // Test focus is possible on button
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ button.focus();
+
+ expect(document.activeElement).toBe(button);
+ });
+
+ it('should have visible focus styles', () => {
+ // Test focus ring classes are present
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button.className).toMatch(/focus-visible:ring/);
+ });
+
+ it('should have unique IDs for multiple instances', () => {
+ // Test that multiple instances have unique IDs (using useId)
+ const sources1 = createMockSources(1);
+ const sources2 = createMockSources(2);
+
+ const { container } = render(
+ <>
+
+
+ >
+ );
+
+ const buttons = screen.getAllByRole('button');
+ const regions = container.querySelectorAll('[role="region"]');
+
+ // IDs should be unique
+ expect(buttons[0].id).not.toBe(buttons[1].id);
+ expect(regions[0].id).not.toBe(regions[1].id);
+ });
+ });
+
+ // ==========================================================================
+ // Props Tests
+ // ==========================================================================
+
+ describe('props', () => {
+ it('should pass showScores prop to SourceCards', () => {
+ // Test that showScores propagates to child components
+ const sources = createMockSources(1);
+ sources[0].score = 0.95;
+
+ render( );
+
+ // Score should be visible on the source card
+ expect(screen.getByText('95%')).toBeInTheDocument();
+ });
+
+ it('should not show scores when showScores is false', () => {
+ // Test default behavior (showScores = false)
+ const sources = createMockSources(1);
+ sources[0].score = 0.95;
+
+ render(
+
+ );
+
+ // Score should not be visible
+ expect(screen.queryByText('95%')).not.toBeInTheDocument();
+ });
+
+ it('should not show scores by default', () => {
+ // Test that showScores defaults to false
+ const sources = createMockSources(1);
+ sources[0].score = 0.85;
+
+ render( );
+
+ expect(screen.queryByText('85%')).not.toBeInTheDocument();
+ });
+
+ it('should apply className prop to container', () => {
+ // Test className is applied
+ const sources = createMockSources(1);
+
+ const { container } = render(
+
+ );
+
+ const mainContainer = container.firstChild as HTMLElement;
+ expect(mainContainer).toHaveClass('custom-class', 'mt-8');
+ });
+
+ it('should apply defaultExpanded prop correctly', () => {
+ // Test defaultExpanded initial state
+ // Note: defaultExpanded only sets the initial state, it does not update when changed
+ // We need to test two separate renders
+ const sources = createMockSources(1);
+
+ // Test collapsed by default
+ const { unmount } = render( );
+ let button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ unmount();
+
+ // Test expanded by default
+ render( );
+ button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should pass additional HTML attributes to container', () => {
+ // Test that other props spread to container div
+ const sources = createMockSources(1);
+
+ render(
+
+ );
+
+ const container = screen.getByTestId('citations-container');
+ expect(container).toHaveAttribute('title', 'Source citations');
+ });
+ });
+
+ // ==========================================================================
+ // Edge Cases Tests
+ // ==========================================================================
+
+ describe('edge cases', () => {
+ it('should handle single source correctly', () => {
+ // Test with exactly one source
+ const sources = createMockSources(1);
+
+ render( );
+
+ expect(screen.getByText('1 Source')).toBeInTheDocument();
+ expect(screen.getByText('Chapter 1 > Section 1')).toBeInTheDocument();
+ });
+
+ it('should handle many sources correctly', () => {
+ // Test with large number of sources
+ const sources = createMockSources(20);
+
+ render( );
+
+ expect(screen.getByText('20 Sources')).toBeInTheDocument();
+ });
+
+ it('should preserve source order', () => {
+ // Test that sources are rendered in provided order
+ const sources = [
+ createMockSource({ id: '1', headingPath: 'First Source' }),
+ createMockSource({ id: '2', headingPath: 'Second Source' }),
+ createMockSource({ id: '3', headingPath: 'Third Source' }),
+ ];
+
+ render( );
+
+ const articles = screen.getAllByRole('article');
+
+ // Check order is preserved
+ expect(articles[0]).toHaveAttribute(
+ 'aria-label',
+ expect.stringContaining('First Source')
+ );
+ expect(articles[1]).toHaveAttribute(
+ 'aria-label',
+ expect.stringContaining('Second Source')
+ );
+ expect(articles[2]).toHaveAttribute(
+ 'aria-label',
+ expect.stringContaining('Third Source')
+ );
+ });
+
+ it('should handle sources with missing optional fields', () => {
+ // Test sources without score
+ const sources = [
+ createMockSource({ id: '1', score: undefined }),
+ createMockSource({ id: '2', score: undefined }),
+ ];
+
+ render(
+
+ );
+
+ // Should render without crashing
+ expect(screen.getByText('2 Sources')).toBeInTheDocument();
+ // No percentages should be visible
+ expect(screen.queryByText(/%/)).not.toBeInTheDocument();
+ });
+
+ it('should handle sources with special characters', () => {
+ // Test sources with special characters in text
+ const sources = [
+ createMockSource({
+ id: '1',
+ headingPath: 'Chapter <1> & Section "2"',
+ text: 'Formula: T = (a + b) / 2 where a > 0 & b < 100',
+ }),
+ ];
+
+ render( );
+
+ expect(screen.getByText('Chapter <1> & Section "2"')).toBeInTheDocument();
+ });
+
+ it('should apply staggered animation delays to source cards', () => {
+ // Test animation delay styles
+ const sources = createMockSources(3);
+
+ render( );
+
+ const articles = screen.getAllByRole('article');
+
+ // Check animation delays are applied (0ms, 50ms, 100ms)
+ expect(articles[0]).toHaveStyle({ animationDelay: '0ms' });
+ expect(articles[1]).toHaveStyle({ animationDelay: '50ms' });
+ expect(articles[2]).toHaveStyle({ animationDelay: '100ms' });
+ });
+
+ it('should cap animation delay at 200ms', () => {
+ // Test animation delay cap for many sources
+ const sources = createMockSources(10);
+
+ render( );
+
+ const articles = screen.getAllByRole('article');
+
+ // Fifth and later sources should have 200ms delay (index 4+ = 200ms)
+ expect(articles[4]).toHaveStyle({ animationDelay: '200ms' });
+ expect(articles[9]).toHaveStyle({ animationDelay: '200ms' });
+ });
+
+ it('should not apply animation styles when collapsed', () => {
+ // Test that animation is disabled when collapsed
+ const sources = createMockSources(3);
+
+ const { container } = render( );
+
+ // When collapsed, articles are inside aria-hidden region so we need to query directly
+ const articles = container.querySelectorAll('article');
+
+ // Animation should be none when collapsed
+ articles.forEach((article) => {
+ expect(article.className).toMatch(/animate-none/);
+ });
+ });
+
+ it('should handle undefined sources gracefully', () => {
+ // Test with undefined-like values (empty array)
+ // Note: TypeScript prevents passing undefined, but empty array is allowed
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should use unique keys for source cards', () => {
+ // Test that source IDs are used as keys (no key warnings in console)
+ const sources = createMockSources(3);
+
+ // This should not produce any React key warnings
+ render( );
+
+ expect(screen.getAllByRole('article')).toHaveLength(3);
+ });
+ });
+
+ // ==========================================================================
+ // Integration Tests
+ // ==========================================================================
+
+ describe('integration', () => {
+ it('should render complete component with all features enabled', () => {
+ // Test full rendering with all props
+ const sources = createMockSources(3);
+
+ render(
+
+ );
+
+ // Count label
+ expect(screen.getByText('3 Sources')).toBeInTheDocument();
+
+ // All source cards
+ expect(screen.getAllByRole('article')).toHaveLength(3);
+
+ // Scores should be visible
+ expect(screen.getByText('90%')).toBeInTheDocument();
+ });
+
+ it('should maintain state across multiple expand/collapse cycles', async () => {
+ // Test state persistence
+ const user = userEvent.setup();
+ const sources = createMockSources(2);
+
+ render( );
+
+ const button = screen.getByRole('button');
+
+ // Cycle through states multiple times
+ for (let i = 0; i < 3; i++) {
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ await user.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+
+ await user.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ }
+ });
+
+ it('should work with fireEvent as alternative to userEvent', () => {
+ // Test with synchronous fireEvent
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(button);
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('should correctly toggle aria-hidden on content region', async () => {
+ // Test aria-hidden updates on content
+ const user = userEvent.setup();
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const region = container.querySelector('[role="region"]');
+
+ expect(region).toHaveAttribute('aria-hidden', 'true');
+
+ await user.click(screen.getByRole('button'));
+ expect(region).toHaveAttribute('aria-hidden', 'false');
+
+ await user.click(screen.getByRole('button'));
+ expect(region).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should render source cards that are individually expandable', async () => {
+ // Test that nested SourceCards work correctly
+ const sources = [
+ createMockSource({
+ id: '1',
+ text: 'A'.repeat(200), // Long enough to need truncation
+ }),
+ ];
+
+ render( );
+
+ // SourceCard should have its own expand button
+ const buttons = screen.getAllByRole('button');
+ // One for SourceCitations, one for SourceCard
+ expect(buttons.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('should handle rapid toggle clicks', async () => {
+ // Test rapid clicking doesn't break state
+ const user = userEvent.setup();
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+
+ // Rapid clicks
+ await user.click(button);
+ await user.click(button);
+ await user.click(button);
+ await user.click(button);
+
+ // Should be in stable state (4 clicks = collapsed)
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+ });
+
+ // ==========================================================================
+ // Visual Tests
+ // ==========================================================================
+
+ describe('visual styling', () => {
+ it('should have top border separator', () => {
+ // Test border styling
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const mainContainer = container.firstChild as HTMLElement;
+ expect(mainContainer.className).toMatch(/border-t/);
+ });
+
+ it('should have proper padding and margin', () => {
+ // Test spacing classes
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const mainContainer = container.firstChild as HTMLElement;
+ expect(mainContainer.className).toMatch(/mt-4/);
+ expect(mainContainer.className).toMatch(/pt-4/);
+ });
+
+ it('should have hover styles on toggle button', () => {
+ // Test hover classes are present
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button.className).toMatch(/hover:/);
+ });
+
+ it('should have transition classes for smooth animations', () => {
+ // Test transition classes are present
+ const sources = createMockSources(1);
+
+ const { container } = render( );
+
+ const region = container.querySelector('[role="region"]');
+ expect(region?.className).toMatch(/transition/);
+ });
+
+ it('should have group class for coordinated hover states', () => {
+ // Test group class on button for child hover coordination
+ const sources = createMockSources(1);
+
+ render( );
+
+ const button = screen.getByRole('button');
+ expect(button.className).toMatch(/group/);
+ });
+ });
+});
diff --git a/frontend/src/components/chat/chat-container.tsx b/frontend/src/components/chat/chat-container.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eacd8fe15acba7cefe21d78ef8fd9e2eded51f38
--- /dev/null
+++ b/frontend/src/components/chat/chat-container.tsx
@@ -0,0 +1,905 @@
+/**
+ * ChatContainer Component
+ *
+ * The main orchestrating component for the chat interface following a
+ * true Claude-style minimal design philosophy. This component achieves
+ * a seamless, boundary-free aesthetic where the chat interface feels
+ * like a natural extension of the page itself rather than a contained widget.
+ *
+ * @module components/chat/chat-container
+ * @since 1.0.0
+ *
+ * @design
+ * ## Design Philosophy
+ *
+ * This component follows Claude's true minimal design principles:
+ * - **No visible boundaries**: No borders, shadows, or rounded corners on the main container
+ * - **Seamless integration**: Chat interface blends directly into the page background
+ * - **Minimal chrome**: Headers and inputs use subtle separation, not heavy styling
+ * - **Content-first**: Messages flow naturally on the page without visual containment
+ * - **Restrained color**: Purple accent color used sparingly for emphasis
+ * - **Flat design**: Zero shadows for a completely uncluttered feel
+ *
+ * @example
+ * // Basic usage in a page
+ *
+ *
+ * @example
+ * // With custom styling
+ *
+ *
+ * @example
+ * // With initial messages (for session restoration)
+ *
+ */
+
+'use client';
+
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type HTMLAttributes,
+} from 'react';
+import { MessageSquare, AlertCircle, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useChat, useSSE, useProviders } from '@/hooks';
+import type { SSEDoneResult, SSEError } from '@/hooks';
+import {
+ MemoizedChatMessage,
+ ChatInput,
+} from '@/components/chat';
+import type { ChatInputHandle } from '@/components/chat';
+import { EmptyState } from './empty-state';
+import { ErrorState } from './error-state';
+import type { ErrorType } from './error-state';
+import { Button } from '@/components/ui/button';
+import { Spinner } from '@/components/ui/spinner';
+import type { HistoryMessage, Message } from '@/types';
+import { CHAT_CONFIG } from '@/config/constants';
+
+/**
+ * Props for the ChatContainer component.
+ *
+ * Extends standard HTML div attributes for flexibility in styling
+ * and event handling, while providing chat-specific configuration.
+ */
+export interface ChatContainerProps extends Omit<
+ HTMLAttributes,
+ 'title'
+> {
+ /**
+ * Title displayed in the chat header.
+ * Defaults to "pythermalcomfort Chat".
+ *
+ * @default "pythermalcomfort Chat"
+ */
+ title?: string;
+
+ /**
+ * Whether to show the internal chat header.
+ * Set to false when the title is rendered at the page level.
+ *
+ * @default true
+ */
+ showHeader?: boolean;
+
+ /**
+ * Initial messages to populate the chat.
+ * Useful for restoring conversation state from localStorage or URL params.
+ *
+ * @default []
+ */
+ initialMessages?: Message[];
+
+ /**
+ * Callback fired when messages change.
+ * Useful for persisting conversation to localStorage.
+ *
+ * @param messages - The updated messages array
+ */
+ onMessagesChange?: (messages: Message[]) => void;
+}
+
+/**
+ * ChatHeader Component
+ *
+ * Renders a minimal, unobtrusive header section that stays out of the way.
+ * Follows Claude-style design where the header is subtle and doesn't
+ * distract from the main chat content.
+ *
+ * Note: Provider selection has been moved to the input area (ChatInput)
+ * to match Claude's UI pattern where the model selector is near the
+ * send button.
+ *
+ * @design
+ * - Small icon and text size to minimize visual weight
+ * - Muted colors so it doesn't compete with chat content
+ * - Very subtle bottom separator (minimal opacity)
+ * - Left-aligned, compact layout
+ *
+ * @param title - The main title text
+ *
+ * @internal
+ */
+function ChatHeader({
+ title,
+}: {
+ title: string;
+}): React.ReactElement {
+ return (
+
+ {/*
+ * Icon in purple - matches brand color.
+ */}
+
+
+ {/* Title - original size, positioned far left */}
+
+ {title}
+
+
+ );
+}
+
+/**
+ * ErrorBanner Component
+ *
+ * Displays a subtle error message that doesn't dominate the interface.
+ * Uses a lighter error styling with reduced opacity backgrounds and
+ * softer colors for a less alarming appearance.
+ *
+ * @design
+ * - Light error background (reduced opacity) for subtle presence
+ * - No border for cleaner appearance
+ * - Smaller, less intrusive icon
+ * - Gentle dismiss button
+ *
+ * @param message - The error message to display
+ * @param onDismiss - Callback fired when the dismiss button is clicked
+ *
+ * @internal
+ */
+function ErrorBanner({
+ message,
+ onDismiss,
+}: {
+ message: string;
+ onDismiss: () => void;
+}): React.ReactElement {
+ return (
+
+ {/* Small error icon - less prominent for minimal design */}
+
+
+ {/* Error message with softer color */}
+
{message}
+
+ {/* Minimal dismiss button */}
+
+
+
+
+ );
+}
+
+/**
+ * LoadingOverlay Component
+ *
+ * A minimal loading indicator using the purple accent color.
+ * Designed to be subtle and unobtrusive while still communicating
+ * that a response is being generated.
+ *
+ * @design
+ * - Purple spinner to match the brand color
+ * - Muted text for the loading message
+ * - Compact layout that doesn't disrupt the message flow
+ * - Fade-in animation for smooth appearance
+ *
+ * @internal
+ */
+function LoadingOverlay(): React.ReactElement {
+ return (
+
+ {/* Purple spinner - matches the minimal design accent color */}
+
+ {/* Subtle loading text - doesn't demand attention */}
+
+ Generating response...
+
+
+ );
+}
+
+/**
+ * Parsed error information for determining display type and retry behavior.
+ *
+ * @internal
+ */
+interface ParsedError {
+ /** The type of error for visual differentiation */
+ type: ErrorType;
+ /** Seconds to wait before retrying (for quota errors) */
+ retryAfterSeconds?: number;
+}
+
+/**
+ * Parse an SSE error to determine its type and retry information.
+ *
+ * Maps SSE error properties to ErrorState types:
+ * - Network errors (TypeError, fetch failures) -> 'network'
+ * - Errors with retryAfter (503 quota exceeded) -> 'quota'
+ * - Other errors -> 'general'
+ *
+ * @param error - The SSE error to parse
+ * @returns Parsed error with type and optional retry delay
+ *
+ * @internal
+ */
+function parseErrorType(error: SSEError): ParsedError {
+ // Network errors get special styling
+ if (error.isNetworkError) {
+ return { type: 'network' };
+ }
+
+ // Errors with retryAfter are quota/rate limit errors (HTTP 503)
+ if (error.retryAfter !== undefined && error.retryAfter > 0) {
+ return {
+ type: 'quota',
+ // Convert milliseconds to seconds for display
+ retryAfterSeconds: Math.ceil(error.retryAfter / 1000),
+ };
+ }
+
+ // Default to general error
+ return { type: 'general' };
+}
+
+/**
+ * ChatContainer Component
+ *
+ * The main chat interface component following true Claude-style minimal design.
+ * This component orchestrates all chat functionality while achieving a seamless,
+ * boundary-free aesthetic where messages flow naturally on the page background.
+ *
+ * @remarks
+ * ## Architecture
+ *
+ * The ChatContainer is composed of several sub-components:
+ *
+ * 1. **ChatHeader**: Minimal header with icon, title, and provider toggle (very subtle border)
+ * 2. **Message List**: Spacious scrollable area with generous message spacing
+ * 3. **EmptyState**: Clean welcome state with example questions
+ * 4. **ChatInput**: Minimal input area with very subtle top separator
+ * 5. **ErrorBanner**: Unobtrusive error display above the input
+ * 6. **LoadingOverlay**: Purple-themed loading indicator
+ *
+ * ## Design Philosophy
+ *
+ * This component implements true Claude-style minimal design:
+ *
+ * - **No visible boundaries**: Main container has no borders, shadows, or rounded corners
+ * - **Seamless integration**: Chat interface blends directly into the page background
+ * - **Minimal chrome**: Headers and inputs use very subtle separators (20% opacity borders)
+ * - **Content-first**: Messages flow naturally without visual containment
+ * - **Zero shadows**: Completely flat design with no elevation effects
+ * - **Generous spacing**: gap-6 between messages for content breathing room
+ * - **Purple accents**: Brand color used sparingly for icons and spinners
+ *
+ * ## State Management
+ *
+ * Uses the `useChat` hook for all state management:
+ * - `messages`: Array of all chat messages
+ * - `isLoading`: Whether a response is being generated
+ * - `error`: Current error message (if any)
+ *
+ * ## Auto-Scroll Behavior
+ *
+ * The message list automatically scrolls to the bottom when:
+ * - A new message is added
+ * - The assistant response is updated (streaming)
+ *
+ * Smooth scroll behavior is used for a polished feel. The scroll
+ * is triggered via a `useEffect` that watches the messages array.
+ *
+ * ## SSE Streaming Integration
+ *
+ * The submit handler uses the useSSE hook to stream responses from the
+ * backend. Tokens are appended to the assistant message in real-time,
+ * and the final response includes source citations from RAG retrieval.
+ *
+ * ## Performance Optimizations
+ *
+ * - Uses `MemoizedChatMessage` to prevent unnecessary re-renders
+ * - Callbacks are memoized with `useCallback`
+ * - Refs are used for direct DOM manipulation (auto-scroll)
+ * - Lazy loading patterns preserved for heavy dependencies
+ *
+ * ## Accessibility
+ *
+ * - Main region is marked with `role="region"` and proper aria-label
+ * - Error messages use `role="alert"` for screen reader announcements
+ * - Loading state is announced via `aria-live` region
+ * - All interactive elements are keyboard accessible
+ *
+ * ## Responsive Design
+ *
+ * The chat container is designed to work across all screen sizes:
+ * - Full viewport height on mobile
+ * - Constrained max-width on larger screens
+ * - Proper padding and spacing at all breakpoints
+ *
+ * @param props - ChatContainer component props
+ * @returns React element containing the complete chat interface
+ *
+ * @see {@link ChatContainerProps} for full prop documentation
+ * @see {@link useChat} for state management details
+ */
+const ChatContainer = forwardRef(
+ (
+ {
+ title = 'pythermalcomfort Chat',
+ showHeader = true,
+ initialMessages = [],
+ onMessagesChange,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ /**
+ * Initialize the useChat hook for state management.
+ *
+ * This hook provides all message state and actions needed
+ * for the complete chat functionality.
+ */
+ const {
+ messages,
+ isLoading,
+ error,
+ addMessage,
+ updateLastMessage,
+ setIsLoading,
+ setError,
+ clearError,
+ } = useChat({
+ initialMessages,
+ onMessagesChange,
+ });
+
+ /**
+ * Initialize the useProviders hook for provider selection.
+ *
+ * This hook provides the selected provider to pass to the SSE stream.
+ * When selectedProvider is null, auto mode is active and the backend
+ * will select the best available provider.
+ */
+ const { selectedProvider } = useProviders();
+
+ /**
+ * Error type state for determining which error UI to show.
+ *
+ * - 'quota': HTTP 503 errors with retry-after header
+ * - 'network': Connection/fetch failures
+ * - 'general': All other errors
+ *
+ * Used to show appropriate ErrorState styling and behavior.
+ */
+ const [errorType, setErrorType] = useState(null);
+
+ /**
+ * Seconds until retry is allowed (for quota errors).
+ *
+ * Populated when the server returns a 503 with Retry-After header.
+ * The ErrorState component uses this to show a countdown timer.
+ */
+ const [retryAfterSeconds, setRetryAfterSeconds] = useState(
+ null
+ );
+
+ /**
+ * Ref to track if the user manually aborted the stream.
+ * Used to distinguish user cancellation from error states.
+ */
+ const userAbortedRef = useRef(false);
+
+ /**
+ * Ref to store the last submitted query for retry functionality.
+ *
+ * When an error occurs and the user clicks retry, we need to re-submit
+ * the same query. This ref preserves the query across renders.
+ */
+ const lastQueryRef = useRef(null);
+
+ /**
+ * Initialize the useSSE hook for streaming responses.
+ *
+ * Callbacks are wired to update the chat state as tokens
+ * arrive and when the stream completes or errors.
+ */
+ const { startStream, abort, isStreaming } = useSSE({
+ /**
+ * Handle incoming tokens during streaming.
+ * Appends each token to the last message content.
+ */
+ onToken: useCallback(
+ (content: string) => {
+ updateLastMessage((prev) => prev + content);
+ },
+ [updateLastMessage]
+ ),
+
+ /**
+ * Handle successful stream completion.
+ * Updates the message with the final response and sources.
+ */
+ onDone: useCallback(
+ (result: SSEDoneResult) => {
+ updateLastMessage(result.response, {
+ isStreaming: false,
+ sources: result.sources,
+ });
+ setIsLoading(false);
+ },
+ [updateLastMessage, setIsLoading]
+ ),
+
+ /**
+ * Handle stream errors.
+ *
+ * This callback processes errors from the SSE stream and updates the UI
+ * appropriately based on the error type:
+ *
+ * 1. If user manually aborted, skip error handling entirely
+ * 2. Parse the error to determine type (network, quota, or general)
+ * 3. Update error state (type, message, retry delay)
+ * 4. If there are existing messages, update the last assistant message
+ * to show an error occurred. The ErrorBanner will handle display.
+ * 5. If no messages exist, the ErrorState component will be shown
+ * in place of the EmptyState (handled in render logic)
+ */
+ onError: useCallback(
+ (error: SSEError) => {
+ // Don't show error if user manually aborted
+ if (userAbortedRef.current) {
+ userAbortedRef.current = false;
+ return;
+ }
+
+ // Parse the error to determine type and retry behavior
+ const parsed = parseErrorType(error);
+
+ // Update error state for UI rendering decisions
+ setErrorType(parsed.type);
+ setRetryAfterSeconds(parsed.retryAfterSeconds ?? null);
+ setError(error.message);
+ setIsLoading(false);
+
+ // Only update the assistant message if we have messages in the chat.
+ // For empty state errors, we show the full ErrorState component instead.
+ if (messages.length > 0) {
+ updateLastMessage(
+ 'Sorry, I encountered an error. Please try again.',
+ {
+ isStreaming: false,
+ }
+ );
+ }
+ },
+ [setError, setIsLoading, updateLastMessage, messages.length]
+ ),
+ });
+
+ /**
+ * Ref to the messages container for auto-scroll functionality.
+ * We scroll this container to the bottom when new messages arrive.
+ */
+ const messagesContainerRef = useRef(null);
+
+ /**
+ * Ref to the ChatInput component for programmatic control.
+ * Used to populate the input when example questions are clicked.
+ */
+ const chatInputRef = useRef(null);
+
+ /**
+ * Ref to track the end of the messages list.
+ * Used as the target for scroll-into-view behavior.
+ */
+ const messagesEndRef = useRef(null);
+
+ /**
+ * Auto-scroll effect that triggers when messages change.
+ *
+ * Scrolls the message list to the bottom with smooth behavior
+ * whenever the messages array is updated. This ensures users
+ * always see the latest message without manual scrolling.
+ *
+ * Implementation note: We use a slight delay to ensure the DOM
+ * has updated before scrolling. This prevents scroll jitter
+ * during rapid streaming updates.
+ */
+ useEffect(() => {
+ // Only scroll if there are messages
+ if (messages.length === 0) return;
+
+ // Use requestAnimationFrame for smooth scroll after DOM update
+ const timeoutId = requestAnimationFrame(() => {
+ messagesEndRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end',
+ });
+ });
+
+ return () => cancelAnimationFrame(timeoutId);
+ }, [messages]);
+
+ /**
+ * Handle example question clicks from EmptyState.
+ *
+ * Populates the chat input with the clicked question,
+ * allowing users to submit it or modify before sending.
+ */
+ const handleExampleClick = useCallback((question: string) => {
+ chatInputRef.current?.setValue(question);
+ chatInputRef.current?.focus();
+ }, []);
+
+ /**
+ * Clear all error state including type and retry information.
+ *
+ * This is an enhanced version of clearError that also resets
+ * the errorType and retryAfterSeconds state. Used when:
+ * - User dismisses an error
+ * - A new query is submitted
+ * - Retry is initiated
+ */
+ const handleClearError = useCallback(() => {
+ clearError();
+ setErrorType(null);
+ setRetryAfterSeconds(null);
+ }, [clearError]);
+
+ /**
+ * Handle aborting the current stream.
+ *
+ * Called when the user wants to cancel an in-progress response.
+ * Marks the abort as user-initiated to prevent error display.
+ *
+ * Note: This function is available for future use when a cancel button
+ * is added to the UI. Currently prefixed with underscore to satisfy
+ * the linter, but the functionality is fully implemented.
+ *
+ * @example
+ * // Usage with a cancel button:
+ * Cancel
+ */
+ const _handleAbort = useCallback(() => {
+ userAbortedRef.current = true;
+ abort();
+ setIsLoading(false);
+ // Update the last message to show it was cancelled
+ updateLastMessage((prev) => prev || 'Response cancelled.', {
+ isStreaming: false,
+ });
+ }, [abort, setIsLoading, updateLastMessage]);
+
+ /**
+ * Handle message submission from ChatInput.
+ *
+ * Initiates an SSE stream to the backend:
+ * 1. Clears any existing errors (including type and retry info)
+ * 2. Stores the query for potential retry
+ * 3. Aborts any in-progress stream
+ * 4. Adds the user message to the conversation
+ * 5. Creates a placeholder assistant message
+ * 6. Starts the SSE stream with the selected provider
+ *
+ * @param content - The user's message content
+ */
+ const handleSubmit = useCallback(
+ (content: string) => {
+ // Guard against empty submissions
+ if (!content.trim()) return;
+
+ // Clear any existing errors (including error type and retry info)
+ handleClearError();
+
+ // Store the query for retry functionality
+ // This allows us to re-submit the same query if an error occurs
+ lastQueryRef.current = content;
+
+ // If already streaming, abort the previous stream
+ if (isStreaming) {
+ userAbortedRef.current = true;
+ abort();
+ }
+
+ // =====================================================================
+ // Build conversation history for multi-turn context
+ // =====================================================================
+ // Extract the most recent messages (before adding the new user message)
+ // Filter out incomplete streaming messages and limit to maxHistoryForAPI
+ // This provides context to the LLM for follow-up questions
+ const history: HistoryMessage[] = messages
+ .filter((msg) => !msg.isStreaming && msg.content.trim())
+ .slice(-CHAT_CONFIG.maxHistoryForAPI)
+ .map((msg) => ({
+ role: msg.role,
+ content: msg.content,
+ }));
+
+ // Add the user's message to the conversation
+ addMessage('user', content);
+
+ // Add a placeholder for the assistant's response
+ // Mark it as streaming so it shows the loading cursor
+ addMessage('assistant', '', { isStreaming: true });
+
+ // Set loading state
+ setIsLoading(true);
+
+ // Reset the abort flag before starting new stream
+ userAbortedRef.current = false;
+
+ // Start the SSE stream with the selected provider and conversation history
+ // Pass undefined instead of null for auto mode
+ startStream(content, selectedProvider ?? undefined, history);
+ },
+ [
+ addMessage,
+ setIsLoading,
+ handleClearError,
+ isStreaming,
+ abort,
+ startStream,
+ selectedProvider,
+ messages,
+ ]
+ );
+
+ /**
+ * Handle retry after an error occurs.
+ *
+ * Clears the error state and re-submits the last query.
+ * For empty state errors (no messages), this will submit the query fresh.
+ * For mid-conversation errors, this will add a new message pair.
+ *
+ * Note: For network errors where the query never reached the server,
+ * this effectively gives the user a second chance to submit.
+ */
+ const handleRetry = useCallback(() => {
+ handleClearError();
+ if (lastQueryRef.current) {
+ handleSubmit(lastQueryRef.current);
+ }
+ }, [handleClearError, handleSubmit]);
+
+ /**
+ * Determine if we should show the empty state.
+ * Only show when there are no messages in the conversation.
+ */
+ const showEmptyState = messages.length === 0;
+
+ return (
+
+ {/* Optional internal header - can be hidden when title is at page level */}
+ {showHeader &&
}
+
+ {/*
+ * Messages Area - Scrollable container with generous spacing.
+ * Increased padding and gap for content breathing room.
+ */}
+
+ {/*
+ * Content rendering logic:
+ *
+ * 1. Empty state WITH error -> Show full-page ErrorState component
+ * This provides a prominent error display when the user hasn't
+ * started a conversation yet (e.g., first query fails).
+ *
+ * 2. Empty state WITHOUT error -> Show EmptyState with examples
+ * The normal welcome state with example questions.
+ *
+ * 3. Messages exist -> Show message list
+ * The ErrorBanner handles errors during conversation.
+ */}
+ {showEmptyState && error ? (
+ /* Full-page error state for empty chat with error */
+
+ ) : showEmptyState ? (
+ /* Normal empty state with example questions */
+
+ ) : (
+ <>
+ {/* Render all messages with increased spacing (gap-6) */}
+ {messages.map((message) => (
+
+ ))}
+
+ {/* Minimal loading indicator with purple spinner */}
+ {isLoading &&
}
+
+ {/* Scroll anchor - used for auto-scroll functionality */}
+
+ >
+ )}
+
+
+ {/*
+ * Error Banner - Shown above input for mid-conversation errors.
+ *
+ * Only display the ErrorBanner when:
+ * - There IS an error
+ * - There ARE messages in the chat (not empty state)
+ *
+ * For empty state errors, we show the full ErrorState component
+ * instead (handled above), so we hide the banner in that case.
+ */}
+ {error && !showEmptyState && (
+
+ )}
+
+ {/*
+ * Input Area - Fixed at bottom with minimal visual separation.
+ * True Claude-style: very subtle top border, no heavy containers.
+ * The input blends naturally into the page.
+ */}
+
+
+
+
+ );
+ }
+);
+
+/* Display name for React DevTools debugging */
+ChatContainer.displayName = 'ChatContainer';
+
+export { ChatContainer };
diff --git a/frontend/src/components/chat/chat-input.tsx b/frontend/src/components/chat/chat-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f63bf355c9063c0b3980aaf039f2e99400eec8fb
--- /dev/null
+++ b/frontend/src/components/chat/chat-input.tsx
@@ -0,0 +1,721 @@
+/**
+ * ChatInput Component
+ *
+ * A refined, modern chat input component inspired by Claude's design language.
+ * Features a clean, minimal aesthetic with subtle purple accents and smooth
+ * transitions for a premium user experience.
+ *
+ * @module components/chat/chat-input
+ * @since 1.0.0
+ *
+ * ## Design Philosophy
+ *
+ * This component follows Claude's design principles:
+ * - **Minimal and clean**: Reduced visual noise with purposeful whitespace
+ * - **Subtle interactions**: Gentle hover states and smooth transitions
+ * - **Focus on content**: The user's input is the hero, not the UI chrome
+ * - **Accessible by default**: High contrast ratios and clear focus states
+ *
+ * ## Visual Design Details
+ *
+ * ### Container
+ * - Clean white background with subtle border
+ * - Rounded corners (12px) for a friendly, modern feel
+ * - Soft shadow on focus for depth without being distracting
+ * - Purple glow effect on focus using box-shadow (not ring)
+ *
+ * ### Textarea
+ * - Borderless design that blends with the container
+ * - Muted placeholder text for visual hierarchy
+ * - Smooth auto-grow behavior without jarring size changes
+ *
+ * ### Send Button
+ * - Circular shape for visual distinction
+ * - Purple gradient background matching brand colors
+ * - Subtle scale animation on hover for tactile feedback
+ * - Clear disabled state without being visually jarring
+ *
+ * ### Supporting Elements
+ * - Character counter in subtle, smaller text
+ * - Keyboard hints in lighter color, smaller size
+ * - All supporting text uses muted colors to not compete with main input
+ *
+ * @example
+ * // Basic usage
+ * handleSubmit(message)} />
+ *
+ * @example
+ * // With loading state
+ *
+ *
+ * @example
+ * // Custom placeholder and max length
+ *
+ */
+
+'use client';
+
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+ type FormEvent,
+ type KeyboardEvent,
+ type ChangeEvent,
+} from 'react';
+import { Send } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Spinner } from '@/components/ui/spinner';
+import { ProviderSelector } from './provider-selector';
+
+/**
+ * Maximum height for the auto-growing textarea in pixels.
+ * After reaching this height, the textarea becomes scrollable.
+ * This value ensures the input doesn't dominate the viewport.
+ */
+const MAX_TEXTAREA_HEIGHT = 200;
+
+/**
+ * Minimum height for the textarea in pixels.
+ * Ensures consistent appearance even with empty content.
+ * Matches standard single-line input height for visual consistency.
+ */
+const MIN_TEXTAREA_HEIGHT = 56;
+
+/**
+ * Default character limit for messages.
+ * Can be overridden via the maxLength prop.
+ * 1000 characters balances detailed questions with API limits.
+ */
+const DEFAULT_MAX_LENGTH = 1000;
+
+/**
+ * Default placeholder text for the input.
+ * Encourages users to ask questions in a friendly tone.
+ */
+const DEFAULT_PLACEHOLDER = 'Ask your question...';
+
+/**
+ * Ref handle exposed by ChatInput for imperative control.
+ *
+ * Allows parent components to programmatically control the input,
+ * useful for accessibility features or external form management.
+ */
+export interface ChatInputHandle {
+ /** Focus the textarea input */
+ focus: () => void;
+ /** Clear the input value */
+ clear: () => void;
+ /** Get the current input value */
+ getValue: () => string;
+ /** Set the input value programmatically */
+ setValue: (value: string) => void;
+}
+
+/**
+ * Props for the ChatInput component.
+ *
+ * Designed for flexibility with sensible defaults, supporting
+ * both controlled and uncontrolled usage patterns.
+ */
+export interface ChatInputProps {
+ /**
+ * Callback fired when the user submits a message.
+ * Called with the trimmed message content.
+ * The input is automatically cleared after submission.
+ *
+ * @param message - The trimmed message text
+ */
+ onSubmit: (message: string) => void;
+
+ /**
+ * Whether the input is in a loading/processing state.
+ * Disables the input and shows a loading spinner in the submit button.
+ *
+ * @default false
+ */
+ isLoading?: boolean;
+
+ /**
+ * Whether the input is disabled.
+ * Separate from isLoading to allow disabling without loading indicator.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Placeholder text for the textarea.
+ *
+ * @default "Ask a question about pythermalcomfort..."
+ */
+ placeholder?: string;
+
+ /**
+ * Maximum character length for the input.
+ * Shows a character counter when approaching the limit.
+ *
+ * @default 1000
+ */
+ maxLength?: number;
+
+ /**
+ * Whether to show the character count indicator.
+ * Shows when the user has typed more than 80% of maxLength.
+ *
+ * @default true
+ */
+ showCharacterCount?: boolean;
+
+ /**
+ * Whether to auto-focus the input on mount.
+ * Useful for immediate input availability.
+ *
+ * @default true
+ */
+ autoFocus?: boolean;
+
+ /**
+ * Additional CSS classes for the container.
+ */
+ className?: string;
+
+ /**
+ * Additional CSS classes for the textarea element.
+ */
+ textareaClassName?: string;
+}
+
+/**
+ * ChatInput Component
+ *
+ * A feature-rich chat input component optimized for conversational interfaces
+ * with a refined, Claude-inspired visual design.
+ *
+ * @remarks
+ * ## Features
+ *
+ * ### Auto-Growing Textarea
+ * The textarea automatically grows as the user types, up to a maximum height
+ * of 200px. After reaching the max height, the content becomes scrollable.
+ * This provides a seamless typing experience without manual resizing.
+ *
+ * ### Keyboard Support
+ * - **Enter**: Submits the message (when not empty and not loading)
+ * - **Shift+Enter**: Inserts a new line (for multi-line messages)
+ * - Submission is blocked when the input is empty or whitespace-only
+ *
+ * ### Character Limit
+ * - Configurable maximum character limit (default: 1000)
+ * - Visual counter appears when approaching the limit (>80%)
+ * - Counter turns red when at or over the limit
+ * - Submission is blocked when over the limit
+ *
+ * ### Loading State
+ * - When isLoading is true, the textarea and button are disabled
+ * - The submit button shows a loading spinner instead of the send icon
+ * - Prevents accidental double-submission
+ *
+ * ### Accessibility
+ * - Proper labeling via aria-label on the textarea
+ * - Submit button has aria-label for screen readers
+ * - Disabled state is communicated via aria-disabled
+ * - Character counter is announced to screen readers
+ *
+ * ## Performance
+ * - Uses requestAnimationFrame for smooth height calculations
+ * - Memoized callbacks to prevent unnecessary re-renders
+ * - Auto-focus uses useEffect to avoid hydration mismatches
+ *
+ * @param props - ChatInput component props
+ * @returns React element containing the chat input form
+ *
+ * @see {@link ChatInputProps} for full prop documentation
+ * @see {@link ChatInputHandle} for imperative API
+ */
+const ChatInput = forwardRef(
+ (
+ {
+ onSubmit,
+ isLoading = false,
+ disabled = false,
+ placeholder = DEFAULT_PLACEHOLDER,
+ maxLength = DEFAULT_MAX_LENGTH,
+ showCharacterCount = true,
+ autoFocus = true,
+ className,
+ textareaClassName,
+ },
+ ref
+ ) => {
+ /**
+ * Internal state for the input value.
+ * Controlled internally with external access via ref handle.
+ */
+ const [value, setValue] = useState('');
+
+ /**
+ * Track focus state for container styling.
+ * Used to apply the purple glow effect on focus.
+ */
+ const [isFocused, setIsFocused] = useState(false);
+
+ /**
+ * Ref to the textarea element for height calculations and focus.
+ */
+ const textareaRef = useRef(null);
+
+ /**
+ * Computed values for UI state
+ */
+ const characterCount = value.length;
+ const isOverLimit = characterCount > maxLength;
+ const isNearLimit = characterCount > maxLength * 0.8;
+ const isEmpty = value.trim().length === 0;
+ const isDisabled = disabled || isLoading;
+ const canSubmit = !isEmpty && !isOverLimit && !isDisabled;
+
+ /**
+ * Reset textarea height to minimum.
+ * Called after submission or clearing.
+ *
+ * Declared before useImperativeHandle since it's used in the handle.
+ */
+ const resetTextareaHeight = useCallback(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = `${MIN_TEXTAREA_HEIGHT}px`;
+ }
+ }, []);
+
+ /**
+ * Adjust textarea height based on content.
+ *
+ * Algorithm:
+ * 1. Reset height to auto to get accurate scrollHeight
+ * 2. Set height to scrollHeight, clamped to min/max bounds
+ *
+ * Uses direct style manipulation for performance (avoids re-render).
+ * Declared before useImperativeHandle since it's used in the handle.
+ */
+ const adjustTextareaHeight = useCallback(() => {
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+
+ // Reset height to recalculate scrollHeight accurately
+ textarea.style.height = 'auto';
+
+ // Calculate new height within bounds
+ const newHeight = Math.min(
+ Math.max(textarea.scrollHeight, MIN_TEXTAREA_HEIGHT),
+ MAX_TEXTAREA_HEIGHT
+ );
+
+ textarea.style.height = `${newHeight}px`;
+ }, []);
+
+ /**
+ * Expose imperative handle for parent component control.
+ *
+ * This allows parent components to:
+ * - Focus the input (e.g., after closing a modal)
+ * - Clear the input (e.g., on conversation reset)
+ * - Get/set value (e.g., for draft restoration)
+ */
+ useImperativeHandle(
+ ref,
+ () => ({
+ focus: () => {
+ textareaRef.current?.focus();
+ },
+ clear: () => {
+ setValue('');
+ resetTextareaHeight();
+ },
+ getValue: () => value,
+ setValue: (newValue: string) => {
+ setValue(newValue);
+ // Defer height calculation to after state update
+ requestAnimationFrame(adjustTextareaHeight);
+ },
+ }),
+ [value, resetTextareaHeight, adjustTextareaHeight]
+ );
+
+ /**
+ * Handle input value changes.
+ * Updates state and adjusts textarea height.
+ */
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ setValue(event.target.value);
+ // Defer to next frame for accurate height calculation
+ requestAnimationFrame(adjustTextareaHeight);
+ },
+ [adjustTextareaHeight]
+ );
+
+ /**
+ * Handle form submission.
+ * Validates input, calls onSubmit, and clears the input.
+ */
+ const handleSubmit = useCallback(
+ (event?: FormEvent) => {
+ event?.preventDefault();
+
+ // Validate submission conditions
+ if (!canSubmit) return;
+
+ // Get trimmed value for submission
+ const trimmedValue = value.trim();
+
+ // Call parent callback
+ onSubmit(trimmedValue);
+
+ // Clear input and reset height
+ setValue('');
+ resetTextareaHeight();
+
+ // Refocus textarea for continued conversation
+ requestAnimationFrame(() => {
+ textareaRef.current?.focus();
+ });
+ },
+ [canSubmit, value, onSubmit, resetTextareaHeight]
+ );
+
+ /**
+ * Handle keyboard events for submission.
+ *
+ * - Enter without Shift: Submit the message
+ * - Shift+Enter: Insert new line (default behavior)
+ */
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ // Only handle Enter key
+ if (event.key !== 'Enter') return;
+
+ // Shift+Enter: Allow default new line behavior
+ if (event.shiftKey) return;
+
+ // Prevent default to avoid new line insertion
+ event.preventDefault();
+
+ // Submit if conditions are met
+ handleSubmit();
+ },
+ [handleSubmit]
+ );
+
+ /**
+ * Auto-focus effect on mount.
+ * Wrapped in useEffect to avoid hydration mismatches
+ * and respect the autoFocus prop.
+ */
+ useEffect(() => {
+ if (autoFocus && textareaRef.current) {
+ // Small delay to ensure DOM is ready
+ const timeoutId = setTimeout(() => {
+ textareaRef.current?.focus();
+ }, 100);
+
+ return () => clearTimeout(timeoutId);
+ }
+ }, [autoFocus]);
+
+ /**
+ * Calculate character count display text.
+ * Only shown when approaching or exceeding the limit.
+ */
+ const characterCountText = `${characterCount}/${maxLength}`;
+
+ return (
+
+ );
+ }
+);
+
+/* Display name for React DevTools */
+ChatInput.displayName = 'ChatInput';
+
+export { ChatInput };
diff --git a/frontend/src/components/chat/chat-message.tsx b/frontend/src/components/chat/chat-message.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8e1dbf4163f6af9b20f03aed0ddaba8f97d2fac4
--- /dev/null
+++ b/frontend/src/components/chat/chat-message.tsx
@@ -0,0 +1,467 @@
+/**
+ * ChatMessage Component
+ *
+ * A Claude-inspired chat message component that displays messages in a
+ * conversational format with proper alignment:
+ * - User messages: Right-aligned with contained width (bubble style)
+ * - Assistant messages: Left-aligned, full-width, minimal styling
+ *
+ * Design Philosophy (Claude UI Principles):
+ * =========================================
+ * 1. Right-aligned user messages - Creates natural conversation flow
+ * 2. Left-aligned assistant messages - Full-width for content focus
+ * 3. Clean typography - Focus on readability with proper line-height
+ * 4. Minimal chrome - Let the content speak for itself
+ *
+ * @module components/chat/chat-message
+ * @since 1.0.0
+ *
+ * @example
+ * // User message (right-aligned with purple background)
+ *
+ *
+ * @example
+ * // Assistant message with streaming cursor (left-aligned, clean)
+ *
+ *
+ * @example
+ * // Assistant message with sources
+ *
+ */
+
+'use client';
+
+import { forwardRef, type HTMLAttributes, memo } from 'react';
+import { User, Sparkles } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import rehypeHighlight from 'rehype-highlight';
+import { cn } from '@/lib/utils';
+import { formatRelativeTime } from '@/lib/utils';
+import type { Message } from '@/types';
+import { MemoizedSourceCitations } from './source-citations';
+import { CodeBlock } from './code-block';
+
+/**
+ * Props for the ChatMessage component.
+ *
+ * Extends standard HTML div attributes for flexibility in styling
+ * and event handling, while requiring the core message data.
+ */
+export interface ChatMessageProps extends Omit<
+ HTMLAttributes,
+ 'content'
+> {
+ /**
+ * The message object containing all message data.
+ * Includes role, content, timestamp, and optional sources/streaming state.
+ */
+ message: Message;
+
+ /**
+ * Whether to show the timestamp below the message.
+ * Useful to hide timestamps in rapid streaming scenarios.
+ *
+ * @default true
+ */
+ showTimestamp?: boolean;
+
+ /**
+ * Custom CSS class for the message content area.
+ * Separate from the container className for precise styling.
+ */
+ bubbleClassName?: string;
+}
+
+/**
+ * StreamingCursor Component
+ *
+ * Renders an animated blinking cursor to indicate that the assistant
+ * is still generating content. Uses CSS animation for smooth blinking.
+ * The cursor uses a purple tint to match the brand identity.
+ *
+ * @internal
+ */
+function StreamingCursor(): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+/**
+ * MessageAvatar Component
+ *
+ * Renders the avatar icon for either user or assistant messages.
+ * Uses a minimal design approach:
+ * - User: Small purple circle with user icon (maintains brand identity)
+ * - Assistant: Simple sparkle icon without circle background (minimal chrome)
+ *
+ * Avatars are smaller (h-6 w-6) and positioned at the top-left of messages
+ * for a cleaner, more document-like reading experience.
+ *
+ * @param role - The role of the message sender ('user' or 'assistant')
+ * @returns Avatar icon element
+ *
+ * @internal
+ */
+function MessageAvatar({
+ role,
+}: {
+ role: Message['role'];
+}): React.ReactElement {
+ const isUser = role === 'user';
+
+ return (
+
+ {isUser ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/**
+ * MessageContent Component
+ *
+ * Renders the message text content using React Markdown with enhanced
+ * typography for better readability. Supports GFM (tables, lists, etc.)
+ * and syntax highlighting.
+ *
+ * Typography enhancements:
+ * - Slightly larger prose size for comfortable reading
+ * - Proper line-height for long-form content
+ * - Purple-colored links for brand consistency
+ *
+ * @param content - The text content of the message
+ * @param isStreaming - Whether the message is still being streamed
+ * @param isUser - Whether this is a user message (affects link styling)
+ * @returns Formatted message content element
+ *
+ * @internal
+ */
+function MessageContent({
+ content,
+ isStreaming = false,
+ isUser = false,
+}: {
+ content: string;
+ isStreaming?: boolean;
+ isUser?: boolean;
+}): React.ReactElement {
+ return (
+
+
{
+ return (
+
+ {children}
+
+ );
+ },
+ // Custom styling for links
+ // User messages: light underlined links on purple background
+ // Assistant messages: purple links for brand consistency
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
+ a: ({ node, className, children, ...props }: any) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {content}
+
+
+ {/* Show purple-tinted blinking cursor when message is still streaming */}
+ {isStreaming &&
}
+
+ );
+}
+
+/**
+ * ChatMessage Component
+ *
+ * A Claude-inspired chat message component with proper message alignment:
+ * - User messages: Right-aligned with contained width
+ * - Assistant messages: Left-aligned, full-width
+ *
+ * @remarks
+ * ## Visual Design (Claude-Inspired)
+ * - **User messages**: Right-aligned with purple gradient background,
+ * max-width constrained for natural conversation flow. No avatar
+ * shown for cleaner appearance.
+ * - **Assistant messages**: Left-aligned, full-width with avatar icon.
+ * Clean text directly on the page background.
+ *
+ * ## Layout (Claude-Style)
+ * - User messages right-aligned with max-width (80%)
+ * - Assistant messages left-aligned, full-width
+ * - Smaller, more subtle avatars for assistant only
+ * - No bubble borders for assistant messages
+ *
+ * ## Accessibility Features
+ * - Uses `role="article"` for semantic grouping of message content
+ * - Includes `aria-label` describing the message author and time
+ * - Avatars are hidden from screen readers (decorative)
+ * - Streaming cursor is hidden from screen readers
+ *
+ * ## Streaming Support
+ * - When `isStreaming` is true on the message, displays an animated
+ * purple-tinted blinking cursor at the end of the content
+ * - Useful for real-time SSE/WebSocket message updates
+ *
+ * ## Animation
+ * - Messages fade in and slide up on initial render
+ * - Uses CSS animations that respect `prefers-reduced-motion`
+ *
+ * @param props - ChatMessage component props
+ * @returns React element containing the styled chat message
+ *
+ * @see {@link ChatMessageProps} for full prop documentation
+ * @see {@link Message} for the message data structure
+ */
+const ChatMessage = forwardRef(
+ (
+ { message, showTimestamp = true, bubbleClassName, className, ...props },
+ ref
+ ) => {
+ const isUser = message.role === 'user';
+
+ /**
+ * Format the timestamp for screen reader accessibility.
+ * Provides context like "User message from 2 minutes ago"
+ */
+ const timeLabel = formatRelativeTime(message.timestamp);
+ const ariaLabel = `${isUser ? 'Your' : 'Assistant'} message from ${timeLabel}`;
+
+ return (
+
+ {/*
+ * Message container with conditional styling:
+ * - User: Right-aligned purple bubble with constrained width
+ * - Assistant: Left-aligned, full-width, minimal styling
+ */}
+
+ {/* Flex container for avatar and content */}
+
+ {/* Avatar - only show for assistant messages */}
+ {!isUser &&
}
+
+ {/* Message content container */}
+
+ {/* Message text content */}
+
+
+
+
+ {/* Source Citations - Only show for completed assistant messages with sources */}
+ {message.role === 'assistant' &&
+ message.sources &&
+ message.sources.length > 0 &&
+ !message.isStreaming && (
+
+
+
+ )}
+
+ {/* Timestamp - shown below content */}
+ {showTimestamp && (
+
+ {timeLabel}
+
+ )}
+
+
+
+
+ );
+ }
+);
+
+/* Display name for React DevTools */
+ChatMessage.displayName = 'ChatMessage';
+
+/**
+ * Memoized ChatMessage for performance optimization.
+ *
+ * Since chat messages are typically rendered in a list and don't change
+ * frequently (except during streaming), memoization prevents unnecessary
+ * re-renders when sibling messages update.
+ *
+ * The component re-renders when:
+ * - message.id changes (new message)
+ * - message.content changes (streaming updates)
+ * - message.isStreaming changes (streaming state)
+ * - showTimestamp or className props change
+ */
+const MemoizedChatMessage = memo(ChatMessage);
+
+/* Export both versions - use Memoized for lists, regular for single messages */
+export { ChatMessage, MemoizedChatMessage };
diff --git a/frontend/src/components/chat/code-block.tsx b/frontend/src/components/chat/code-block.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d37ab5dd65eda6f98f119f9e82995b6447abaed7
--- /dev/null
+++ b/frontend/src/components/chat/code-block.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { Check, Copy, Terminal } from 'lucide-react';
+import { memo, useRef, useState, useCallback } from 'react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+interface CodeBlockProps extends React.HTMLAttributes {
+ inline?: boolean;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * CodeBlock Component
+ *
+ * Renders a code block with syntax highlighting (via rehype-highlight classes),
+ * a language label, and a copy-to-clipboard button.
+ * Designed to work as a custom component for react-markdown.
+ */
+export const CodeBlock = memo(
+ ({ inline, className, children, ...props }: CodeBlockProps) => {
+ // specific ref to the code element for copying
+ const codeRef = useRef(null);
+ const [isCopied, setIsCopied] = useState(false);
+
+ // Extract language from className (format: "language-xyz")
+ // react-markdown (via rehype-highlight) passes "language-xyz" in className
+ const match = /language-(\w+)/.exec(className || '');
+ const language = match ? match[1] : '';
+
+ // Handle copy functionality
+ const handleCopy = useCallback(() => {
+ if (codeRef.current && codeRef.current.textContent) {
+ navigator.clipboard.writeText(codeRef.current.textContent);
+ setIsCopied(true);
+ setTimeout(() => setIsCopied(false), 2000);
+ }
+ }, []);
+
+ // If inline code, render simple styled code tag
+ if (inline) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {/* Header with language and actions */}
+
+
+
+ {language || 'text'}
+
+
+ {isCopied ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Code content */}
+
+
+ );
+ }
+);
+
+CodeBlock.displayName = 'CodeBlock';
diff --git a/frontend/src/components/chat/empty-state.tsx b/frontend/src/components/chat/empty-state.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cfffc083d76dd40c5bcfcb8863c0146c34b3f2f2
--- /dev/null
+++ b/frontend/src/components/chat/empty-state.tsx
@@ -0,0 +1,658 @@
+/**
+ * EmptyState Component - Claude-Style Minimal Design
+ *
+ * A welcoming empty state component displayed when the chat has no messages.
+ * Provides a friendly introduction to the RAG chatbot with example questions
+ * that users can click to quickly start a conversation.
+ *
+ * Features a premium custom SVG illustration representing thermal comfort concepts
+ * with subtle animations that respect user motion preferences. The design follows
+ * Claude's minimal aesthetic - content flows naturally on the page background
+ * without visible card boundaries or container styling.
+ *
+ * ## Design Philosophy
+ * - No glassmorphism or card containers
+ * - Content floats naturally on the page background
+ * - Suggestion buttons are lightweight and feel native to the page
+ * - Clean, distraction-free interface that focuses on content
+ *
+ * ## Purple Theme Colors
+ * The component uses the following purple color palette via CSS custom properties:
+ * - purple-100 (#f3e8ff): Light backgrounds, window fills
+ * - purple-200 (#e9d5ff): Secondary elements, doors
+ * - purple-300 (#d8b4fe): Decorative elements, medium accents
+ * - purple-400 (#c084fc): Icons, gradient starts
+ * - purple-500 (#a855f7): Primary accent color
+ * - purple-600 (#9333ea): Gradient ends, active states
+ * - purple-700 (#7c3aed): Strokes, borders
+ *
+ * @module components/chat/empty-state
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage in ChatContainer
+ * handleQuestionClick(question)} />
+ *
+ * @example
+ * // With custom styling
+ *
+ */
+
+'use client';
+
+import { memo, useCallback, type HTMLAttributes } from 'react';
+import { MessageSquare } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * ThermalComfortIllustration Component - Purple Theme
+ *
+ * A premium, custom SVG illustration representing thermal comfort concepts.
+ * Features a stylized building with temperature waves and comfort indicators,
+ * all rendered with a cohesive purple color scheme.
+ *
+ * ## Design Elements (Purple Theme)
+ * - Central building silhouette with purple-400 to purple-600 gradient
+ * - Thermometer element with purple-300 to purple-600 gradient fill
+ * - Flowing wave lines with purple-300 to purple-400 gradient
+ * - Circular comfort zone with purple-200 to purple-400 radial gradient
+ * - Building windows in purple-100, door in purple-200
+ * - Strokes in purple-700 for definition
+ * - Comfort indicator dots in purple-300, purple-400, purple-500
+ *
+ * ## Animation Features
+ * - Gentle floating animation on the main container
+ * - Subtle pulse effect on the comfort zone circle
+ * - Wave lines with staggered opacity animations
+ * - All animations respect `prefers-reduced-motion`
+ *
+ * @param className - Optional CSS classes to apply
+ * @returns SVG element with thermal comfort illustration in purple theme
+ *
+ * @internal
+ */
+function ThermalComfortIllustration({
+ className,
+}: {
+ className?: string;
+}): React.ReactElement {
+ return (
+
+ {/* ============================================
+ SVG Definitions: Purple Gradients and Filters
+ All gradients use purple color scheme for cohesive design
+ ============================================ */}
+
+ {/*
+ Building Gradient - Purple Tones
+ Creates depth on the main building structure
+ Uses purple-400 (#c084fc) to purple-600 (#9333ea)
+ */}
+
+ {/* Start: Medium purple - creates highlight on top-left */}
+
+ {/* End: Rich purple - creates shadow on bottom-right */}
+
+
+
+ {/*
+ Comfort Zone Gradient - Soft Purple Radial
+ Creates a glowing orb effect behind the illustration
+ Uses purple-200 (#e9d5ff) to purple-400 (#c084fc)
+ */}
+
+ {/* Center: Soft purple with high opacity */}
+
+ {/* Middle: Medium-light purple, fading */}
+
+ {/* Edge: Medium purple, nearly transparent */}
+
+
+
+ {/*
+ Thermometer Fill Gradient - Purple Temperature Indicator
+ Vertical gradient simulating mercury/temperature level
+ Uses purple-300 (#d8b4fe) to purple-600 (#9333ea)
+ */}
+
+ {/* Bottom: Lighter purple - cooler indication */}
+
+ {/* Middle: Main purple - neutral zone */}
+
+ {/* Top: Rich purple - warmer indication */}
+
+
+
+ {/*
+ Wave Gradient - Air Flow Representation
+ Horizontal gradient for flowing wave lines
+ Uses purple-300 (#d8b4fe) to purple-400 (#c084fc)
+ */}
+
+ {/* Start: Faded purple - creates trailing edge effect */}
+
+ {/* Center: Visible purple - wave peak */}
+
+ {/* End: Faded purple - creates leading edge effect */}
+
+
+
+
+ {/* ============================================
+ Background: Comfort Zone Circle
+ A soft radial purple element suggesting optimal thermal conditions
+ Animates with subtle pulse for organic feel
+ ============================================ */}
+
+
+ {/* ============================================
+ Building Silhouette
+ Central element representing the built environment
+ Uses purple gradient with purple-700 strokes for definition
+ Features windows (purple-100) and door (purple-200)
+ ============================================ */}
+
+ {/* Main building body - pitched roof style
+ Filled with purple gradient for depth */}
+
+
+ {/* Roof accent line - emphasizes the pitched roof
+ Thicker stroke for visual hierarchy */}
+
+
+ {/* Windows - grid pattern suggesting occupancy
+ Uses purple-100 for bright, welcoming appearance */}
+ {/* Left column of windows */}
+
+
+
+ {/* Right column of windows */}
+
+
+
+ {/* Door - central entrance
+ Uses purple-200 for subtle differentiation from windows */}
+
+ {/* Door handle - small detail in purple-600 */}
+
+
+
+ {/* ============================================
+ Thermometer Element
+ Positioned to the right, indicating temperature measurement
+ Uses purple theme: outline in purple-100, fill with purple gradient
+ ============================================ */}
+
+ {/* Thermometer outline - bulb-style design
+ Light purple-100 background, purple-400 border */}
+
+
+ {/* Thermometer fill - animated warmth level
+ Purple gradient creates temperature indication
+ Pulses to suggest active measurement */}
+
+
+ {/* Temperature measurement marks - tick marks on thermometer
+ Purple-400 for subtle visibility */}
+
+
+
+
+
+ {/* ============================================
+ Wave Lines - Air Flow Representation
+ Animated purple curves suggesting thermal circulation
+ Staggered timing creates flowing, organic effect
+ ============================================ */}
+ {/* Top wave - highest opacity for visual hierarchy */}
+
+
+
+
+ {/* Middle wave - 0.3s delay for staggered motion */}
+
+
+
+
+ {/* Bottom wave - 0.6s delay completes the flowing sequence */}
+
+
+
+
+ {/* ============================================
+ Comfort Indicator Dots - Purple Accents
+ Small circular elements suggesting optimal conditions
+ Each dot uses different purple shade for visual interest:
+ - Top-left: purple-400 (medium purple)
+ - Bottom-right: purple-300 (lighter purple)
+ - Bottom-left: purple-500 (main accent purple)
+ ============================================ */}
+ {/* Top-left indicator dot - purple-400 */}
+
+ {/* Bottom-right indicator dot - purple-300 (lighter) */}
+
+ {/* Bottom-left indicator dot - purple-500 (main accent) */}
+
+
+ );
+}
+
+/**
+ * Example questions displayed in the empty state.
+ *
+ * These are pre-defined questions that demonstrate the types of queries
+ * the RAG chatbot can answer about pythermalcomfort. Limited to 2 questions
+ * for a cleaner, less overwhelming interface:
+ * 1. PMV model - core thermal comfort calculation
+ * 2. Adaptive comfort - alternative model for naturally ventilated spaces
+ *
+ * @internal
+ */
+const EXAMPLE_QUESTIONS = [
+ 'What is the PMV model and how do I calculate it?',
+ 'How do I use the adaptive comfort model in pythermalcomfort?',
+] as const;
+
+/**
+ * Props for the EmptyState component.
+ *
+ * Extends standard HTML div attributes for flexibility in styling
+ * and event handling. The onClick prop is omitted to prevent conflicts
+ * with the internal example click handling.
+ */
+export interface EmptyStateProps extends Omit<
+ HTMLAttributes,
+ 'onClick'
+> {
+ /**
+ * Callback fired when a user clicks an example question.
+ * The clicked question text is passed as the argument.
+ * This should typically populate the chat input with the question.
+ *
+ * @param question - The text of the clicked example question
+ */
+ onExampleClick: (question: string) => void;
+}
+
+/**
+ * ExampleQuestionButton Component - Claude-Style Minimal Design
+ *
+ * An individual example question rendered as a clickable button.
+ * Uses a lightweight, text-focused design that feels native to the page
+ * without heavy container styling.
+ *
+ * ## Design Philosophy
+ * - Minimal visual weight - no heavy borders or shadows
+ * - Subtle hover state using background color change
+ * - Feels like a natural page element, not a contained widget
+ * - Purple icon accent maintains brand consistency
+ *
+ * @param question - The question text to display
+ * @param onClick - Callback fired when the button is clicked
+ *
+ * @internal
+ */
+function ExampleQuestionButton({
+ question,
+ onClick,
+}: {
+ question: string;
+ onClick: () => void;
+}): React.ReactElement {
+ return (
+
+ {/* Question mark icon - purple-500 for visual accent
+ Aligned to top of text for multi-line questions */}
+
+ {/* Question text - inherits foreground color */}
+ {question}
+
+ );
+}
+
+/**
+ * EmptyState Component - Claude-Style Minimal Design
+ *
+ * Displays a welcoming interface when the chat has no messages. This component
+ * serves as the initial state of the chat, providing users with context about
+ * what the chatbot can do and offering quick-start example questions.
+ *
+ * @remarks
+ * ## Visual Design - Claude-Style Minimal
+ *
+ * The component follows Claude's minimal design philosophy:
+ * - No glassmorphism, card containers, or visible boundaries
+ * - Content flows naturally on the page background
+ * - Lightweight suggestion buttons without heavy styling
+ * - Clean typography with proper hierarchy
+ * - Focus on content, not decoration
+ *
+ * ## Thermal Comfort Illustration (Purple Theme)
+ *
+ * The illustration (`ThermalComfortIllustration`) includes:
+ * - Stylized building with purple-400 to purple-600 gradient
+ * - Thermometer with purple-300 to purple-600 gradient fill
+ * - Wave lines in purple-300 to purple-400 gradient
+ * - Comfort zone circle with purple-200 to purple-400 radial gradient
+ * - Building windows in purple-100, door in purple-200
+ * - Strokes in purple-700 for definition
+ * - Comfort dots in purple-300, purple-400, purple-500
+ * - Subtle animations (float, pulse, wave) respecting `prefers-reduced-motion`
+ *
+ * ## Interaction Pattern
+ *
+ * When users click an example question:
+ * 1. The `onExampleClick` callback is fired with the question text
+ * 2. The parent component (ChatContainer) should populate the input
+ * 3. Optionally, the parent can auto-submit the question
+ *
+ * ## Accessibility
+ *
+ * - All example questions are focusable buttons
+ * - Focus states are clearly visible with purple ring
+ * - Illustration is hidden from screen readers (decorative, aria-hidden="true")
+ * - Uses semantic heading hierarchy
+ * - Animations respect `prefers-reduced-motion` via `motion-safe:` prefix
+ *
+ * ## Animation
+ *
+ * The component uses the slide-up animation for a smooth entrance,
+ * creating a polished feel when the chat interface first loads.
+ * The illustration adds additional subtle animations:
+ * - `thermalFloat`: Gentle vertical float (4s cycle)
+ * - `thermalPulse`: Soft breathing effect (2-3s cycles)
+ * - `thermalWave`: Horizontal wave motion (2s cycles with stagger)
+ *
+ * @param props - EmptyState component props
+ * @returns React element containing the welcome interface
+ *
+ * @see {@link EmptyStateProps} for full prop documentation
+ * @see {@link ThermalComfortIllustration} for illustration details
+ */
+function EmptyStateComponent({
+ onExampleClick,
+ className,
+ ...props
+}: EmptyStateProps): React.ReactElement {
+ /**
+ * Create memoized click handlers for each example question.
+ * This prevents creating new function references on each render,
+ * optimizing performance when the component re-renders.
+ */
+ const handleExampleClick = useCallback(
+ (question: string) => {
+ onExampleClick(question);
+ },
+ [onExampleClick]
+ );
+
+ return (
+
+ {/* Content flows directly on page background - no card container
+ Claude-style minimal design with natural spacing */}
+
+ {/* Illustration section - Premium thermal comfort SVG
+ Centered with proper vertical spacing */}
+
+
+ {/* Purple-themed thermal comfort illustration */}
+
+
+
+
+ {/* Title section - Clear hierarchy with heading and description */}
+
+ {/* Main heading - bold and prominent */}
+
+ Ask about thermal comfort and pythermalcomfort
+
+ {/* Descriptive subtitle - provides context */}
+
+ Get answers about thermal comfort models, concepts, standards and the
+ pythermalcomfort library. Your questions are answered using
+ scientific sources and official documentations.
+
+
+
+ {/* Example questions section - Quick-start options */}
+
+ {/* Section label - small caps style */}
+
+ Try asking
+
+
+ {/* Questions grid - responsive 1 or 2 column layout
+ Single column on mobile, two columns on larger screens */}
+
+ {EXAMPLE_QUESTIONS.map((question) => (
+ handleExampleClick(question)}
+ />
+ ))}
+
+
+
+ );
+}
+
+/* Display name for React DevTools debugging */
+EmptyStateComponent.displayName = 'EmptyState';
+
+/**
+ * Memoized EmptyState for performance optimization.
+ *
+ * The EmptyState component doesn't change frequently, so memoization
+ * prevents unnecessary re-renders when the parent component updates.
+ * Only re-renders when onExampleClick callback changes (which should
+ * be stable if defined with useCallback in the parent).
+ */
+const EmptyState = memo(EmptyStateComponent);
+
+export { EmptyState };
diff --git a/frontend/src/components/chat/error-state.tsx b/frontend/src/components/chat/error-state.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bad257dacbd224e110464819f491d651c99e16fa
--- /dev/null
+++ b/frontend/src/components/chat/error-state.tsx
@@ -0,0 +1,652 @@
+/**
+ * ErrorState Component
+ *
+ * A minimal error display component for the RAG chatbot interface. Handles
+ * various error scenarios including quota exceeded (503), network failures,
+ * and general errors with distinct visual styling and user-friendly messaging.
+ *
+ * Features:
+ * - Type-specific visual styling using the design system's color palette:
+ * - Quota errors: Purple accent (via --color-primary-* CSS variables)
+ * - Network errors: Blue semantic color
+ * - General errors: Red semantic color
+ * - Live countdown timer for quota errors with auto-retry
+ * - Retry functionality with disabled state during countdown
+ * - Dismiss capability for clearing errors
+ * - Clean, minimal design with subtle shadows
+ * - Full WCAG AA accessibility compliance
+ * - Smooth entrance animations
+ *
+ * @module components/chat/error-state
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic quota error with countdown (displays with purple theme)
+ * handleRetry()}
+ * onDismiss={() => clearError()}
+ * />
+ *
+ * @example
+ * // Network error with retry button (displays with blue theme)
+ * handleRetry()}
+ * />
+ *
+ * @example
+ * // General error with custom message (displays with red theme)
+ * handleRetry()}
+ * onDismiss={() => clearError()}
+ * />
+ */
+
+'use client';
+
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useState,
+ type HTMLAttributes,
+} from 'react';
+import { AlertTriangle, WifiOff, RefreshCw, Clock, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Error type definitions for visual and content differentiation.
+ *
+ * - `quota`: Service temporarily unavailable due to rate limiting (HTTP 503)
+ * - `network`: Connection issues preventing communication with the server
+ * - `general`: Catch-all for other error types
+ *
+ * @public
+ */
+export type ErrorType = 'quota' | 'network' | 'general';
+
+/**
+ * Props for the ErrorState component.
+ *
+ * Extends standard HTML div attributes for flexibility in styling
+ * and event handling while providing error-specific configuration.
+ *
+ * @public
+ */
+export interface ErrorStateProps
+ extends Omit, 'children'> {
+ /**
+ * Error type for visual distinction and default messaging.
+ * Each type has its own icon, color scheme, and default message.
+ *
+ * - `quota`: Purple styling with clock icon (uses design system's primary color)
+ * - `network`: Blue styling with wifi-off icon (semantic network/connection color)
+ * - `general`: Red styling with alert triangle icon (semantic error color)
+ */
+ type: ErrorType;
+
+ /**
+ * User-friendly error message to display.
+ * If not provided, a default message based on the error type is shown.
+ *
+ * @example
+ * message="Unable to connect to the server. Please try again later."
+ */
+ message?: string;
+
+ /**
+ * Seconds until retry is allowed (primarily for quota errors).
+ * When provided, displays a live countdown timer and disables
+ * the retry button until the countdown reaches zero.
+ *
+ * @example
+ * retryAfter={45} // Shows "Try again in 45s" and counts down
+ */
+ retryAfter?: number;
+
+ /**
+ * Callback fired when the retry button is clicked.
+ * The retry button is only shown when this callback is provided.
+ * For quota errors with retryAfter, the button is disabled during countdown.
+ *
+ * @param event - The click event from the retry button
+ */
+ onRetry?: () => void;
+
+ /**
+ * Callback fired when the dismiss button is clicked.
+ * The dismiss button is only shown when this callback is provided.
+ * Use this to clear the error state in the parent component.
+ */
+ onDismiss?: () => void;
+}
+
+/**
+ * Configuration object for error type-specific styling and content.
+ *
+ * @internal
+ */
+interface ErrorTypeConfig {
+ /** Lucide React icon component */
+ icon: typeof AlertTriangle;
+ /** Default message when no custom message is provided */
+ defaultMessage: string;
+ /** Tailwind classes for the icon container background */
+ iconBgClass: string;
+ /** Tailwind classes for the icon color */
+ iconColorClass: string;
+ /** Tailwind classes for the card border accent */
+ borderAccentClass: string;
+ /** Tailwind classes for action button styling */
+ buttonClass: string;
+}
+
+/**
+ * Error type configuration mapping.
+ *
+ * Defines the visual appearance and default content for each error type.
+ * Uses CSS variables from globals.css for consistent theming.
+ *
+ * Color scheme:
+ * - quota: Purple (via --color-primary-* CSS variables) - indicates temporary unavailability
+ * - network: Blue - semantic color for connectivity issues
+ * - general: Red - semantic color for errors/failures
+ *
+ * @internal
+ */
+const ERROR_TYPE_CONFIGS: Record = {
+ /**
+ * Quota error configuration - Purple theme
+ *
+ * Uses the design system's primary color (purple) via CSS variables.
+ * This ensures the quota error styling automatically follows any
+ * theme changes to the primary color palette.
+ */
+ quota: {
+ icon: Clock,
+ defaultMessage:
+ 'Our service is currently at capacity. Please wait a moment.',
+ /* Purple gradient background for icon circle */
+ iconBgClass:
+ 'bg-gradient-to-br from-[var(--color-primary-100)] to-[var(--color-primary-200)]',
+ /* Purple icon color */
+ iconColorClass: 'text-[var(--color-primary-600)]',
+ /* Purple border accent */
+ borderAccentClass: 'border-[var(--color-primary-300)]',
+ /* Purple button with proper contrast for text */
+ buttonClass:
+ 'bg-[var(--color-primary-500)] text-[var(--color-primary-button-text)] hover:bg-[var(--color-primary-600)] disabled:bg-[var(--color-primary-200)] disabled:text-[var(--foreground-muted)]',
+ },
+ /**
+ * Network error configuration - Blue theme
+ *
+ * Uses semantic blue color for connectivity/network issues.
+ * Blue is commonly associated with information and connection states.
+ */
+ network: {
+ icon: WifiOff,
+ defaultMessage: 'Unable to connect. Please check your internet connection.',
+ /* Blue gradient background for icon circle */
+ iconBgClass: 'bg-gradient-to-br from-blue-100 to-blue-200',
+ /* Blue icon color */
+ iconColorClass: 'text-blue-600',
+ /* Blue border accent */
+ borderAccentClass: 'border-blue-300',
+ /* Blue button styling */
+ buttonClass:
+ 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-200 disabled:text-blue-400',
+ },
+ /**
+ * General error configuration - Red theme
+ *
+ * Uses semantic red color for general errors and failures.
+ * Red is universally recognized as an error/warning indicator.
+ */
+ general: {
+ icon: AlertTriangle,
+ defaultMessage: 'Something went wrong. Please try again.',
+ /* Red gradient background for icon circle */
+ iconBgClass: 'bg-gradient-to-br from-red-100 to-red-200',
+ /* Red icon color */
+ iconColorClass: 'text-red-600',
+ /* Red border accent */
+ borderAccentClass: 'border-red-300',
+ /* Red button styling */
+ buttonClass:
+ 'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-200 disabled:text-red-400',
+ },
+};
+
+/**
+ * Formats seconds into a human-readable countdown string.
+ *
+ * Converts raw seconds into minutes:seconds format when appropriate,
+ * or displays just seconds for shorter durations.
+ *
+ * @param seconds - Number of seconds remaining
+ * @returns Formatted string (e.g., "45s", "1:30")
+ *
+ * @internal
+ */
+function formatCountdown(seconds: number): string {
+ if (seconds <= 0) return '0s';
+
+ if (seconds < 60) {
+ return `${seconds}s`;
+ }
+
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+
+ // Format with leading zero for seconds when showing minutes
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
+}
+
+/**
+ * Custom hook for managing the countdown timer.
+ *
+ * Handles the countdown logic with proper cleanup on unmount.
+ * Returns the current countdown value and whether the countdown is active.
+ *
+ * @param initialSeconds - Starting value for the countdown
+ * @returns Object containing current seconds and active state
+ *
+ * @internal
+ */
+function useCountdown(initialSeconds: number | undefined): {
+ secondsRemaining: number;
+ isCountdownActive: boolean;
+} {
+ // Initialize state with the provided value or 0
+ const [secondsRemaining, setSecondsRemaining] = useState(
+ initialSeconds ?? 0
+ );
+
+ // Determine if countdown is active (has time remaining)
+ const isCountdownActive = secondsRemaining > 0;
+
+ /**
+ * Effect to reset countdown when initialSeconds prop changes AND
+ * handle the countdown timer interval.
+ *
+ * This pattern of calling setState at the start of an effect to reset
+ * state when a prop changes is intentional - it's the standard React
+ * pattern for synchronizing state with props.
+ * See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
+ *
+ * Creates an interval that decrements the counter every second.
+ * Cleans up the interval when:
+ * - Component unmounts
+ * - Countdown reaches zero
+ * - initialSeconds prop changes
+ */
+ useEffect(() => {
+ // Reset the countdown when initialSeconds changes
+ // This is the synchronization pattern - reset state when prop changes
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setSecondsRemaining(initialSeconds ?? 0);
+
+ // Don't start timer if no initial value or zero
+ if (!initialSeconds || initialSeconds <= 0) {
+ return;
+ }
+
+ // Create interval to decrement countdown every second
+ const intervalId = setInterval(() => {
+ setSecondsRemaining((prev) => {
+ // Stop at zero
+ if (prev <= 1) {
+ clearInterval(intervalId);
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ // Cleanup interval on unmount or when initialSeconds changes
+ return () => {
+ clearInterval(intervalId);
+ };
+ }, [initialSeconds]);
+
+ return { secondsRemaining, isCountdownActive };
+}
+
+/**
+ * ErrorState Component
+ *
+ * Displays a minimal error state card with type-specific styling,
+ * optional countdown timer, and action buttons for retry/dismiss.
+ *
+ * @remarks
+ * ## Visual Design
+ *
+ * The component features a clean, minimal card design with:
+ * - Type-specific icon in a gradient circle
+ * - Clear heading and descriptive message
+ * - Optional countdown timer display (purple-themed for quota errors)
+ * - Action buttons (retry and dismiss)
+ * - Subtle shadow for depth without overwhelming the UI
+ *
+ * ## Color Theming
+ *
+ * Each error type uses a distinct color to provide semantic meaning:
+ * - Quota errors: Purple (via CSS variables --color-primary-*)
+ * - Network errors: Blue (semantic connectivity color)
+ * - General errors: Red (semantic error color)
+ *
+ * ## Countdown Timer
+ *
+ * For quota errors (503 responses), the component can display a live
+ * countdown timer that shows when the user can retry:
+ * - Timer updates every second with purple-themed styling
+ * - Retry button is disabled during countdown
+ * - Timer automatically enables retry when it reaches zero
+ * - Proper cleanup on component unmount
+ *
+ * ## Accessibility
+ *
+ * The component follows WCAG AA guidelines:
+ * - Uses `role="alert"` for screen reader announcements
+ * - `aria-live="assertive"` for immediate announcement of errors
+ * - Decorative icons are hidden from screen readers
+ * - All interactive elements are keyboard accessible
+ * - Focus states are clearly visible
+ *
+ * ## Animation
+ *
+ * Uses the slide-up animation for smooth entrance, consistent
+ * with other components in the application.
+ *
+ * @param props - ErrorState component props
+ * @returns React element containing the error display
+ *
+ * @see {@link ErrorStateProps} for full prop documentation
+ */
+function ErrorStateComponent({
+ type,
+ message,
+ retryAfter,
+ onRetry,
+ onDismiss,
+ className,
+ ...props
+}: ErrorStateProps): React.ReactElement {
+ /**
+ * Get configuration for the current error type.
+ * Includes icon, colors, and default message.
+ */
+ const config = ERROR_TYPE_CONFIGS[type];
+
+ /**
+ * Use the countdown hook to manage timer state.
+ * Provides current seconds remaining and active state.
+ */
+ const { secondsRemaining, isCountdownActive } = useCountdown(retryAfter);
+
+ /**
+ * Determine the message to display.
+ * Use custom message if provided, otherwise fall back to type default.
+ */
+ const displayMessage = message || config.defaultMessage;
+
+ /**
+ * Determine if retry button should be disabled.
+ * Disabled during countdown for quota errors.
+ */
+ const isRetryDisabled = type === 'quota' && isCountdownActive;
+
+ /**
+ * Memoized retry handler to prevent unnecessary re-renders.
+ * Only calls onRetry if provided and not disabled.
+ */
+ const handleRetry = useCallback(() => {
+ if (onRetry && !isRetryDisabled) {
+ onRetry();
+ }
+ }, [onRetry, isRetryDisabled]);
+
+ /**
+ * Memoized dismiss handler to prevent unnecessary re-renders.
+ */
+ const handleDismiss = useCallback(() => {
+ if (onDismiss) {
+ onDismiss();
+ }
+ }, [onDismiss]);
+
+ // Get the icon component from config
+ const IconComponent = config.icon;
+
+ return (
+
+ {/* Card container with clean, minimal design */}
+
+ {/* Dismiss button - positioned in top right corner */}
+ {onDismiss && (
+
+
+
+ )}
+
+ {/* Icon section */}
+
+
+ {/* Title section */}
+
+
+ {type === 'quota' && 'Service Temporarily Unavailable'}
+ {type === 'network' && 'Connection Lost'}
+ {type === 'general' && 'Oops! Something Went Wrong'}
+
+
+ {displayMessage}
+
+
+
+ {/*
+ Countdown timer display (only for quota errors with retryAfter)
+ Uses purple theme via CSS variables (--color-primary-*)
+ to match the quota error styling
+ */}
+ {type === 'quota' && retryAfter !== undefined && retryAfter > 0 && (
+
+ {/* Clock icon in purple */}
+
+ {/* Countdown text in purple for visual consistency */}
+
+ {isCountdownActive
+ ? `Try again in ${formatCountdown(secondsRemaining)}`
+ : 'Ready to retry'}
+
+
+ )}
+
+ {/*
+ Action buttons section
+ Button color matches the error type for visual consistency:
+ - Quota: Purple button (via CSS variables)
+ - Network: Blue button (semantic connectivity color)
+ - General: Red button (semantic error color)
+ */}
+ {onRetry && (
+
+
+
+ {isRetryDisabled ? 'Please wait...' : 'Try Again'}
+
+
+ )}
+
+
+ );
+}
+
+/* Display name for React DevTools */
+ErrorStateComponent.displayName = 'ErrorState';
+
+/**
+ * Memoized ErrorState for performance optimization.
+ *
+ * The ErrorState component benefits from memoization as it may be
+ * rendered within frequently updating parent components. Only re-renders
+ * when props change, preventing unnecessary work during chat updates.
+ *
+ * @public
+ */
+const ErrorState = memo(ErrorStateComponent);
+
+export { ErrorState };
diff --git a/frontend/src/components/chat/index.ts b/frontend/src/components/chat/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ad4864f92fc10dbae785bab8800f15973f9cb638
--- /dev/null
+++ b/frontend/src/components/chat/index.ts
@@ -0,0 +1,189 @@
+/**
+ * Chat Components Barrel Export
+ *
+ * Re-exports all chat-specific components for convenient imports.
+ * These components work together to create the chat interface for
+ * the RAG chatbot application.
+ *
+ * @module components/chat
+ * @since 1.0.0
+ *
+ * @example
+ * // Import individual components
+ * import { ChatMessage, ChatInput, ChatContainer } from '@/components/chat';
+ *
+ * @example
+ * // Use components together in a page
+ * import { ChatContainer } from '@/components/chat';
+ *
+ * function ChatPage() {
+ * return ;
+ * }
+ *
+ * @example
+ * // Build a custom chat interface with lower-level components
+ * import {
+ * MemoizedChatMessage,
+ * ChatInput,
+ * EmptyState,
+ * } from '@/components/chat';
+ * import { useChat } from '@/hooks/use-chat';
+ *
+ * function CustomChatInterface() {
+ * const { messages, addMessage, isLoading } = useChat();
+ *
+ * return (
+ *
+ * {messages.length === 0 ? (
+ * addMessage('user', q)} />
+ * ) : (
+ * messages.map(msg => (
+ *
+ * ))
+ * )}
+ * addMessage('user', content)} isLoading={isLoading} />
+ *
+ * );
+ * }
+ */
+
+/**
+ * ChatMessage - Display component for individual chat messages.
+ *
+ * Renders user and assistant messages with distinct styling:
+ * - User messages: Right-aligned, primary orange background
+ * - Assistant messages: Left-aligned, secondary gray background
+ *
+ * Supports streaming state with animated cursor.
+ *
+ * @see {@link ./chat-message} for full documentation
+ */
+export { ChatMessage, MemoizedChatMessage } from './chat-message';
+export type { ChatMessageProps } from './chat-message';
+
+/**
+ * ChatInput - Input component for composing chat messages.
+ *
+ * Features:
+ * - Auto-growing textarea (up to 200px)
+ * - Enter to submit, Shift+Enter for new line
+ * - Character limit indicator
+ * - Loading state with spinner
+ * - Imperative handle for programmatic control
+ *
+ * @see {@link ./chat-input} for full documentation
+ */
+export { ChatInput } from './chat-input';
+export type { ChatInputProps, ChatInputHandle } from './chat-input';
+
+/**
+ * ChatContainer - Main orchestrating component for the chat interface.
+ *
+ * The primary component for integrating a full chat experience.
+ * Combines header, message list, empty state, and input area.
+ *
+ * Features:
+ * - Header with title and optional subtitle
+ * - Scrollable message list with auto-scroll
+ * - Empty state with example questions
+ * - Fixed input area at bottom
+ * - Error banner for error display
+ * - Loading indicator during response generation
+ *
+ * @see {@link ./chat-container} for full documentation
+ */
+export { ChatContainer } from './chat-container';
+export type { ChatContainerProps } from './chat-container';
+
+/**
+ * EmptyState - Welcome component shown when chat has no messages.
+ *
+ * Displays a friendly introduction with example questions that
+ * users can click to quickly start a conversation.
+ *
+ * Features:
+ * - Welcoming icon and title
+ * - Descriptive subtitle
+ * - Clickable example question buttons
+ * - Modern card design with glassmorphism effect
+ *
+ * @see {@link ./empty-state} for full documentation
+ */
+export { EmptyState } from './empty-state';
+export type { EmptyStateProps } from './empty-state';
+
+/**
+ * ErrorState - Error display component for various error scenarios.
+ *
+ * A premium error state component that handles quota exceeded (503),
+ * network failures, and general errors with distinct visual styling.
+ *
+ * Features:
+ * - Type-specific visual styling (quota, network, general)
+ * - Live countdown timer for quota errors with auto-retry
+ * - Retry and dismiss functionality
+ * - Glassmorphism card design
+ * - Full WCAG AA accessibility compliance
+ *
+ * @see {@link ./error-state} for full documentation
+ */
+export { ErrorState } from './error-state';
+export type { ErrorStateProps, ErrorType } from './error-state';
+
+/**
+ * ProviderToggle - LLM provider selection component (pill layout).
+ *
+ * A premium horizontal pill/chip layout for selecting LLM providers
+ * with real-time status indicators and cooldown timers.
+ *
+ * Features:
+ * - Horizontal pill layout with "Auto" as first option
+ * - Green pulse dot for available providers
+ * - Red dot with countdown for providers in cooldown
+ * - Full keyboard navigation (arrow keys, Home/End)
+ * - Radio group accessibility semantics
+ * - Responsive: stacks vertically on mobile
+ *
+ * @see {@link ./provider-toggle} for full documentation
+ */
+export { ProviderToggle } from './provider-toggle';
+export type { ProviderToggleProps } from './provider-toggle';
+
+/**
+ * ProviderSelector - Claude-style dropdown for LLM provider selection.
+ *
+ * A minimal dropdown selector styled after Claude's model selector,
+ * designed to be placed in the input area near the send button.
+ *
+ * Features:
+ * - Compact dropdown trigger showing selected provider
+ * - Dropdown menu with all available providers
+ * - Status indicators (available, cooldown)
+ * - Cooldown timer display
+ * - Keyboard navigation support
+ *
+ * @see {@link ./provider-selector} for full documentation
+ */
+export { ProviderSelector } from './provider-selector';
+export type { ProviderSelectorProps } from './provider-selector';
+
+/**
+ * Sidebar - Collapsible left sidebar showing current provider/model.
+ *
+ * Displays the currently selected LLM provider and model name.
+ * Collapses to icon-only mode for a minimal footprint.
+ *
+ * Features:
+ * - Shows provider name and model name
+ * - Collapsible to icon-only mode
+ * - Persists collapse state to localStorage
+ * - Status indicators for availability
+ *
+ * @see {@link ./sidebar} for full documentation
+ */
+export { Sidebar } from './sidebar';
+export type { SidebarProps } from './sidebar';
+
+// Future exports (to be implemented in subsequent steps):
+// export * from './source-card';
+// export * from './message-list';
diff --git a/frontend/src/components/chat/provider-selector.tsx b/frontend/src/components/chat/provider-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9af265bd11b529b2626fec5a2ea966d31c515abb
--- /dev/null
+++ b/frontend/src/components/chat/provider-selector.tsx
@@ -0,0 +1,359 @@
+/**
+ * ProviderSelector Component - Claude-Style Model Dropdown
+ *
+ * A minimal dropdown selector for LLM providers, styled after Claude's
+ * model selector in the input area. Shows the currently selected provider
+ * with a chevron indicator, and opens a dropdown for selection.
+ *
+ * @module components/chat/provider-selector
+ * @since 1.0.0
+ *
+ * @example
+ * // Usage in ChatInput
+ *
+ */
+
+'use client';
+
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type ReactElement,
+} from 'react';
+import { ChevronDown, Zap, Check, Clock } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useProviders, type ProviderStatus } from '@/hooks';
+
+/**
+ * Props for the ProviderSelector component.
+ */
+export interface ProviderSelectorProps {
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/**
+ * Provider option type including Auto option.
+ * @internal
+ */
+interface ProviderOption {
+ id: string | null;
+ name: string;
+ isAvailable: boolean;
+ cooldownSeconds: number | null;
+}
+
+/**
+ * Auto option configuration.
+ * @internal
+ */
+const AUTO_OPTION: ProviderOption = {
+ id: null,
+ name: 'Auto',
+ isAvailable: true,
+ cooldownSeconds: null,
+};
+
+/**
+ * Format cooldown seconds.
+ * @internal
+ */
+function formatCooldown(seconds: number): string {
+ if (seconds <= 0) return '';
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ if (minutes > 0) {
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
+ }
+ return `${remainingSeconds}s`;
+}
+
+/**
+ * Transform providers to options.
+ * @internal
+ */
+function transformToOptions(providers: ProviderStatus[]): ProviderOption[] {
+ const providerOptions: ProviderOption[] = providers.map((provider) => ({
+ id: provider.id,
+ name: provider.name,
+ isAvailable: provider.isAvailable,
+ cooldownSeconds: provider.cooldownSeconds,
+ }));
+ return [AUTO_OPTION, ...providerOptions];
+}
+
+/**
+ * Status dot indicator.
+ * @internal
+ */
+function StatusDot({ isAvailable, hasCooldown }: { isAvailable: boolean; hasCooldown: boolean }): ReactElement {
+ if (hasCooldown) {
+ return ;
+ }
+ if (isAvailable) {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * ProviderSelector Component
+ *
+ * A Claude-style dropdown for selecting LLM providers. Positioned in the
+ * input area, it shows the current selection with a minimal design that
+ * expands to show all available providers.
+ */
+export function ProviderSelector({ className }: ProviderSelectorProps): ReactElement {
+ const {
+ providers,
+ selectedProvider,
+ selectProvider,
+ isLoading,
+ } = useProviders();
+
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+ const buttonRef = useRef(null);
+
+ /* Transform providers to options */
+ const options = useMemo(() => transformToOptions(providers), [providers]);
+
+ /* Find selected option */
+ const selectedOption = useMemo(() => {
+ return options.find((opt) => opt.id === selectedProvider) ?? AUTO_OPTION;
+ }, [options, selectedProvider]);
+
+ /* Handle outside click to close dropdown */
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [isOpen]);
+
+ /* Handle escape key to close */
+ useEffect(() => {
+ function handleEscape(event: globalThis.KeyboardEvent) {
+ if (event.key === 'Escape' && isOpen) {
+ setIsOpen(false);
+ buttonRef.current?.focus();
+ }
+ }
+
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }, [isOpen]);
+
+ /* Toggle dropdown */
+ const handleToggle = useCallback(() => {
+ setIsOpen((prev) => !prev);
+ }, []);
+
+ /* Select provider and close */
+ const handleSelect = useCallback(
+ (id: string | null) => {
+ selectProvider(id);
+ setIsOpen(false);
+ buttonRef.current?.focus();
+ },
+ [selectProvider]
+ );
+
+ /* Keyboard navigation in dropdown */
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent, index: number) => {
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ const nextIndex = (index + 1) % options.length;
+ const nextButton = containerRef.current?.querySelectorAll('[role="option"]')[nextIndex] as HTMLButtonElement;
+ nextButton?.focus();
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ const prevIndex = (index - 1 + options.length) % options.length;
+ const prevButton = containerRef.current?.querySelectorAll('[role="option"]')[prevIndex] as HTMLButtonElement;
+ prevButton?.focus();
+ break;
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ handleSelect(options[index].id);
+ break;
+ }
+ },
+ [options, handleSelect]
+ );
+
+ /* Loading state */
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Trigger Button - Claude-style minimal */}
+
+ {/* Auto icon or status dot */}
+ {selectedOption.id === null ? (
+
+ ) : (
+ 0}
+ />
+ )}
+
+ {/* Provider name */}
+ {selectedOption.name}
+
+ {/* Chevron */}
+
+
+
+ {/* Dropdown Menu */}
+ {isOpen && (
+
+
+ {options.map((option, index) => {
+ const isSelected = option.id === selectedProvider;
+ const hasCooldown = option.cooldownSeconds !== null && option.cooldownSeconds > 0;
+
+ return (
+ handleSelect(option.id)}
+ onKeyDown={(e) => handleKeyDown(e, index)}
+ className={cn(
+ /* Layout */
+ 'w-full flex items-center justify-between gap-3',
+ 'px-3 py-2',
+ /* Typography */
+ 'text-sm',
+ /* Colors */
+ isSelected
+ ? 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'
+ : 'text-gray-700 dark:text-gray-200',
+ /* Hover */
+ !isSelected && 'hover:bg-gray-50 dark:hover:bg-gray-800',
+ /* Disabled appearance for cooldown */
+ hasCooldown && !isSelected && 'opacity-60',
+ /* Focus */
+ 'focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-800',
+ /* Transition */
+ 'transition-colors duration-100',
+ /* Cursor */
+ 'cursor-pointer'
+ )}
+ >
+ {/* Left side: icon/dot and name */}
+
+ {option.id === null ? (
+
+ ) : (
+
+ )}
+ {option.name}
+
+
+ {/* Right side: cooldown or check */}
+
+ {hasCooldown && (
+
+
+ {formatCooldown(option.cooldownSeconds!)}
+
+ )}
+ {isSelected && (
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+ProviderSelector.displayName = 'ProviderSelector';
diff --git a/frontend/src/components/chat/provider-toggle.tsx b/frontend/src/components/chat/provider-toggle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c59f705f72818c2e413f510715e315f3780da652
--- /dev/null
+++ b/frontend/src/components/chat/provider-toggle.tsx
@@ -0,0 +1,756 @@
+'use client';
+
+/**
+ * Provider Toggle Component
+ *
+ * A production-ready, premium UI component for selecting LLM providers.
+ * Displays providers as horizontal selectable pills with real-time status
+ * indicators, cooldown timers, and accessibility-first design.
+ *
+ * @module components/chat/provider-toggle
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage
+ * import { ProviderToggle } from '@/components/chat';
+ *
+ * function ChatHeader() {
+ * return (
+ *
+ * );
+ * }
+ *
+ * @example
+ * // Compact variant for tight spaces
+ *
+ *
+ * @example
+ * // With custom className
+ *
+ */
+
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type ReactElement,
+} from 'react';
+import { Zap, RefreshCw, Clock, AlertCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useProviders, type ProviderStatus } from '@/hooks';
+
+// ============================================================================
+// Type Definitions
+// ============================================================================
+
+/**
+ * Props for the ProviderToggle component.
+ *
+ * @public
+ */
+export interface ProviderToggleProps {
+ /**
+ * Additional CSS classes to apply to the container.
+ */
+ className?: string;
+
+ /**
+ * Smaller variant for tight spaces.
+ * Reduces padding, font sizes, and indicator sizes.
+ *
+ * @default false
+ */
+ compact?: boolean;
+}
+
+/**
+ * Internal type representing a provider option including the "Auto" option.
+ *
+ * @internal
+ */
+interface ProviderOption {
+ /** Unique identifier (null for auto mode) */
+ id: string | null;
+ /** Display name */
+ name: string;
+ /** Description text */
+ description: string;
+ /** Whether the provider is available */
+ isAvailable: boolean;
+ /** Remaining cooldown in seconds (null if not in cooldown) */
+ cooldownSeconds: number | null;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Auto option configuration.
+ * This is always the first option in the provider list.
+ *
+ * @internal
+ */
+const AUTO_OPTION: ProviderOption = {
+ id: null,
+ name: 'Auto',
+ description: 'Automatically select the best available provider',
+ isAvailable: true,
+ cooldownSeconds: null,
+};
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+/**
+ * Format cooldown seconds into a human-readable string.
+ *
+ * @param seconds - Cooldown duration in seconds
+ * @returns Formatted string (e.g., "2m 30s" or "45s")
+ *
+ * @internal
+ */
+function formatCooldown(seconds: number): string {
+ if (seconds <= 0) return '';
+
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+
+ if (minutes > 0) {
+ return remainingSeconds > 0
+ ? `${minutes}m ${remainingSeconds}s`
+ : `${minutes}m`;
+ }
+
+ return `${remainingSeconds}s`;
+}
+
+/**
+ * Transform provider statuses into option format, prepending Auto option.
+ *
+ * @param providers - Array of provider statuses from the hook
+ * @returns Array of provider options with Auto as the first element
+ *
+ * @internal
+ */
+function transformToOptions(providers: ProviderStatus[]): ProviderOption[] {
+ const providerOptions: ProviderOption[] = providers.map((provider) => ({
+ id: provider.id,
+ name: provider.name,
+ description: provider.description,
+ isAvailable: provider.isAvailable,
+ cooldownSeconds: provider.cooldownSeconds,
+ }));
+
+ return [AUTO_OPTION, ...providerOptions];
+}
+
+// ============================================================================
+// Sub-Components
+// ============================================================================
+
+/**
+ * Status indicator dot with pulse animation for available providers.
+ *
+ * @param props - Component props
+ * @param props.status - Current status ('available', 'cooldown', 'unknown')
+ * @param props.compact - Whether to render in compact mode
+ * @returns Status indicator element
+ *
+ * @internal
+ */
+function StatusIndicator({
+ status,
+ compact = false,
+}: {
+ status: 'available' | 'cooldown' | 'unknown';
+ compact?: boolean;
+}): ReactElement {
+ const baseClasses = cn(
+ 'rounded-full shrink-0',
+ compact ? 'h-1.5 w-1.5' : 'h-2 w-2'
+ );
+
+ switch (status) {
+ case 'available':
+ return (
+
+
+ {/* Outer glow ring for emphasis */}
+
+
+
+ );
+
+ case 'cooldown':
+ return ;
+
+ case 'unknown':
+ default:
+ return (
+
+ );
+ }
+}
+
+/**
+ * Inner countdown timer component that handles the countdown logic.
+ *
+ * This component is designed to be used with a key prop that changes
+ * when initialSeconds changes, causing a remount that resets the countdown.
+ * This pattern avoids calling setState synchronously in useEffect.
+ *
+ * @param props - Component props
+ * @param props.initialSeconds - Starting countdown value in seconds
+ * @param props.compact - Whether to render in compact mode
+ * @returns Countdown display element
+ *
+ * @internal
+ */
+function CooldownTimerInner({
+ initialSeconds,
+ compact = false,
+}: {
+ initialSeconds: number;
+ compact?: boolean;
+}): ReactElement {
+ /* Initialize state with the starting value */
+ const [remainingSeconds, setRemainingSeconds] = useState(
+ () => initialSeconds
+ );
+
+ /* Set up decrement interval - runs once on mount */
+ useEffect(() => {
+ /* Don't set up interval if no time remaining */
+ if (initialSeconds <= 0) {
+ return;
+ }
+
+ /* Set up decrement interval */
+ const intervalId = setInterval(() => {
+ setRemainingSeconds((prev) => {
+ const next = prev - 1;
+ if (next <= 0) {
+ clearInterval(intervalId);
+ return 0;
+ }
+ return next;
+ });
+ }, 1000);
+
+ /* Cleanup on unmount */
+ return () => {
+ clearInterval(intervalId);
+ };
+ /* Empty dependency array - only run on mount */
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, []);
+
+ if (remainingSeconds <= 0) {
+ return <>>;
+ }
+
+ return (
+
+
+ {formatCooldown(remainingSeconds)}
+
+ );
+}
+
+/**
+ * Cooldown countdown timer wrapper that handles prop changes via key.
+ *
+ * Uses the key prop pattern to remount the inner component when
+ * initialSeconds changes, which resets the countdown properly without
+ * needing to call setState in useEffect.
+ *
+ * @param props - Component props
+ * @param props.initialSeconds - Starting cooldown value in seconds
+ * @param props.compact - Whether to render in compact mode
+ * @returns Countdown display element
+ *
+ * @internal
+ */
+function CooldownTimer({
+ initialSeconds,
+ compact = false,
+}: {
+ initialSeconds: number;
+ compact?: boolean;
+}): ReactElement {
+ /* Use initialSeconds as key to remount when it changes */
+ return (
+
+ );
+}
+
+/**
+ * Skeleton loader for the provider toggle during loading state.
+ *
+ * @param props - Component props
+ * @param props.compact - Whether to render in compact mode
+ * @returns Skeleton element
+ *
+ * @internal
+ */
+function ProviderToggleSkeleton({
+ compact = false,
+}: {
+ compact?: boolean;
+}): ReactElement {
+ return (
+
+ {/* Render 3 skeleton pills (Auto + 2 providers) */}
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Error state display with refresh button.
+ *
+ * @param props - Component props
+ * @param props.error - Error message to display
+ * @param props.onRefresh - Callback to trigger refresh
+ * @param props.compact - Whether to render in compact mode
+ * @returns Error display element
+ *
+ * @internal
+ */
+function ProviderToggleError({
+ error,
+ onRefresh,
+ compact = false,
+}: {
+ error: string;
+ onRefresh: () => void;
+ compact?: boolean;
+}): ReactElement {
+ return (
+
+ );
+}
+
+/**
+ * Individual provider pill button.
+ *
+ * @param props - Component props
+ * @returns Provider pill element
+ *
+ * @internal
+ */
+function ProviderPill({
+ option,
+ isSelected,
+ onSelect,
+ compact = false,
+ tabIndex,
+ onKeyDown,
+}: {
+ option: ProviderOption;
+ isSelected: boolean;
+ onSelect: (id: string | null) => void;
+ compact?: boolean;
+ tabIndex: number;
+ onKeyDown: (e: KeyboardEvent) => void;
+}): ReactElement {
+ const isAuto = option.id === null;
+ const isUnavailable = !option.isAvailable;
+ const hasCooldown =
+ option.cooldownSeconds !== null && option.cooldownSeconds > 0;
+
+ /* Determine status for indicator */
+ const status: 'available' | 'cooldown' | 'unknown' = useMemo(() => {
+ if (isAuto) return 'available';
+ if (hasCooldown) return 'cooldown';
+ if (option.isAvailable) return 'available';
+ return 'unknown';
+ }, [isAuto, hasCooldown, option.isAvailable]);
+
+ /* Handle click */
+ const handleClick = useCallback(() => {
+ /* Allow selection even if unavailable (user might want to see cooldown) */
+ onSelect(option.id);
+ }, [onSelect, option.id]);
+
+ return (
+
+ {/* Auto icon for the Auto option */}
+ {isAuto && (
+
+ )}
+
+ {/* Status indicator for non-auto options */}
+ {!isAuto && }
+
+ {/* Provider name */}
+ {option.name}
+
+ {/* Cooldown timer (only shown for non-auto options in cooldown) */}
+ {hasCooldown && !isAuto && (
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+/**
+ * Provider Toggle Component
+ *
+ * A premium horizontal pill/chip layout for selecting LLM providers.
+ * Features real-time status indicators, cooldown timers, and full
+ * keyboard navigation support.
+ *
+ * @remarks
+ * ## Features
+ * - Horizontal pill layout with "Auto" as the first option
+ * - Green pulse dot for available providers
+ * - Red dot with countdown timer for providers in cooldown
+ * - Gray dot for unknown/unavailable status
+ * - Selected state with primary color highlight
+ * - Subtle hover scale and shadow transitions
+ * - Responsive: stacks vertically on mobile
+ *
+ * ## Accessibility
+ * - Radio group semantics for selection
+ * - Full keyboard navigation (arrow keys)
+ * - Proper ARIA labels and states
+ * - Focus indicators meeting WCAG AA
+ * - Screen reader announcements for cooldown timers
+ *
+ * ## State Management
+ * - Uses useProviders hook internally for provider status
+ * - Graceful loading state with skeleton
+ * - Error state with refresh button
+ * - Local countdown timer that decrements every second
+ *
+ * @param props - Component props
+ * @returns Provider toggle element
+ *
+ * @example
+ * // Basic usage
+ *
+ *
+ * @example
+ * // Compact mode for headers
+ *
+ */
+export function ProviderToggle({
+ className,
+ compact = false,
+}: ProviderToggleProps): ReactElement {
+ const {
+ providers,
+ selectedProvider,
+ selectProvider,
+ isLoading,
+ error,
+ refresh,
+ } = useProviders();
+
+ /* Transform providers to options format */
+ const options = useMemo(() => transformToOptions(providers), [providers]);
+
+ /* Find the index of the currently selected option */
+ const selectedIndex = useMemo(() => {
+ return options.findIndex((opt) => opt.id === selectedProvider);
+ }, [options, selectedProvider]);
+
+ /* Refs for keyboard navigation */
+ const containerRef = useRef(null);
+
+ /**
+ * Handle keyboard navigation within the radio group.
+ * Supports arrow keys for navigation and Enter/Space for selection.
+ */
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent, currentIndex: number) => {
+ let newIndex = currentIndex;
+ let handled = false;
+
+ switch (e.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ /* Move to next option, wrap around */
+ newIndex = (currentIndex + 1) % options.length;
+ handled = true;
+ break;
+
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ /* Move to previous option, wrap around */
+ newIndex = (currentIndex - 1 + options.length) % options.length;
+ handled = true;
+ break;
+
+ case 'Home':
+ /* Move to first option */
+ newIndex = 0;
+ handled = true;
+ break;
+
+ case 'End':
+ /* Move to last option */
+ newIndex = options.length - 1;
+ handled = true;
+ break;
+ }
+
+ if (handled) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ /* Select the new option */
+ selectProvider(options[newIndex].id);
+
+ /* Focus the new button */
+ const container = containerRef.current;
+ if (container) {
+ const buttons = container.querySelectorAll('button[role="radio"]');
+ const targetButton = buttons[newIndex] as HTMLButtonElement;
+ targetButton?.focus();
+ }
+ }
+ },
+ [options, selectProvider]
+ );
+
+ /* Render loading state */
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ /* Render error state */
+ if (error && providers.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {options.map((option, index) => (
+
handleKeyDown(e, index)}
+ />
+ ))}
+
+ {/* Refresh button (subtle, at the end) */}
+
+
+
+
+ );
+}
+
+/* Display name for React DevTools */
+ProviderToggle.displayName = 'ProviderToggle';
diff --git a/frontend/src/components/chat/sidebar.tsx b/frontend/src/components/chat/sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8104a3f5fe132aa0db44edf78ee73d8bd927a460
--- /dev/null
+++ b/frontend/src/components/chat/sidebar.tsx
@@ -0,0 +1,284 @@
+/**
+ * Sidebar Component
+ *
+ * A collapsible left sidebar that displays the current LLM provider
+ * and model information. Follows a modern design similar to VS Code
+ * or Slack sidebars.
+ *
+ * @module components/chat/sidebar
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage
+ *
+ *
+ * @example
+ * // With custom className
+ *
+ */
+
+'use client';
+
+import {
+ useCallback,
+ useEffect,
+ useState,
+ type ReactElement,
+} from 'react';
+import {
+ ChevronLeft,
+ ChevronRight,
+ Cpu,
+ Info,
+ Zap,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useProviders } from '@/hooks';
+
+/**
+ * Props for the Sidebar component.
+ */
+export interface SidebarProps {
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/**
+ * localStorage key for sidebar collapsed state.
+ * @internal
+ */
+const STORAGE_KEY = 'rag-chatbot-sidebar-collapsed';
+
+/**
+ * Sidebar width constants.
+ * @internal
+ */
+const SIDEBAR_WIDTH = {
+ expanded: 'w-56',
+ collapsed: 'w-12',
+};
+
+/**
+ * Sidebar Component
+ *
+ * A collapsible sidebar showing the current LLM provider and model.
+ * Features:
+ * - Displays provider name and model name
+ * - Collapsible to icon-only mode
+ * - Persists collapse state to localStorage
+ * - Smooth animations
+ */
+export function Sidebar({ className }: SidebarProps): ReactElement {
+ const { providers, selectedProvider } = useProviders();
+
+ // Collapse state with localStorage persistence
+ const [isCollapsed, setIsCollapsed] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ // Load collapsed state from localStorage on mount
+ useEffect(() => {
+ setMounted(true);
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored !== null) {
+ setIsCollapsed(stored === 'true');
+ }
+ }, []);
+
+ // Toggle collapse state
+ const toggleCollapsed = useCallback(() => {
+ setIsCollapsed((prev) => {
+ const newValue = !prev;
+ localStorage.setItem(STORAGE_KEY, String(newValue));
+ return newValue;
+ });
+ }, []);
+
+ // Get current provider info
+ const currentProvider = selectedProvider
+ ? providers.find((p) => p.id === selectedProvider)
+ : null;
+
+ const providerName = selectedProvider
+ ? currentProvider?.name || selectedProvider
+ : 'Auto';
+
+ // Use primaryModel from backend API (fetched via useProviders)
+ // Falls back to "Auto-selected" for auto mode or "Loading..." if not yet fetched
+ const modelName = selectedProvider
+ ? currentProvider?.primaryModel || 'Loading...'
+ : 'Auto-selected';
+
+
+ // Don't render until mounted to avoid hydration mismatch
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Header with collapse button */}
+
+ {!isCollapsed && (
+
+ Model
+
+ )}
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Provider/Model Info */}
+
+
+ {isCollapsed ? (
+ /* Collapsed: Icon only */
+
+ {selectedProvider ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+ /* Expanded: Full info */
+
+ {/* Provider icon and name */}
+
+ {selectedProvider ? (
+
+ ) : (
+
+ )}
+
+ {providerName}
+
+
+
+ {/* Model name */}
+
+
+ {/* Status indicator */}
+ {currentProvider && (
+
+
+
+ {currentProvider.isAvailable
+ ? 'Available'
+ : `Cooldown: ${currentProvider.cooldownSeconds}s`}
+
+
+ )}
+
+ {/* Auto mode indicator */}
+ {!selectedProvider && (
+
+
+
+ Auto-selecting best
+
+
+ )}
+
+ )}
+
+
+ {/* Info section - only when expanded */}
+ {!isCollapsed && (
+
+
+
+
+
+ Shows the currently active model. If a model hits its rate limit,
+ the system automatically switches to the next available model.
+
+
+ {selectedProvider
+ ? `Fallback order: ${currentProvider?.allModels?.slice(0, 3).join(' → ') || 'Loading...'}`
+ : 'Auto mode picks the best available provider automatically.'}
+
+
+
+
+ )}
+
+
+ );
+}
+
+Sidebar.displayName = 'Sidebar';
diff --git a/frontend/src/components/chat/source-card.tsx b/frontend/src/components/chat/source-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6aafda48ff8f164467b4ee06b4a9746afa3b5e4d
--- /dev/null
+++ b/frontend/src/components/chat/source-card.tsx
@@ -0,0 +1,580 @@
+/**
+ * SourceCard Component
+ *
+ * A premium-quality card component for displaying source citations from the
+ * RAG retrieval system. Designed to present document chunk information in a
+ * clean, accessible, and interactive manner.
+ *
+ * This component displays:
+ * - Document heading path (hierarchy breadcrumb)
+ * - Page number badge
+ * - Truncated/expandable source text
+ * - Optional relevance score indicator
+ *
+ * @module components/chat/source-card
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage with required props
+ * Thermal Comfort > PMV Model',
+ * page: 42,
+ * text: 'The PMV model predicts the mean response...',
+ * }}
+ * />
+ *
+ * @example
+ * // With relevance score displayed
+ * Calculations',
+ * page: 78,
+ * text: 'To calculate the operative temperature...',
+ * score: 0.92,
+ * }}
+ * showScore
+ * />
+ *
+ * @example
+ * // Custom truncation length
+ *
+ */
+
+'use client';
+
+import {
+ forwardRef,
+ lazy,
+ memo,
+ Suspense,
+ useCallback,
+ useState,
+ type HTMLAttributes,
+ type KeyboardEvent,
+} from 'react';
+import { cn, truncateText } from '@/lib/utils';
+import type { Source } from '@/types';
+
+/**
+ * Lazy-loaded icons from lucide-react for bundle optimization.
+ *
+ * Icons are only loaded when the component is rendered, reducing
+ * the initial bundle size. This follows the project convention of
+ * lazy-loading heavy dependencies.
+ */
+const FileText = lazy(() =>
+ import('lucide-react').then((mod) => ({ default: mod.FileText }))
+);
+const ChevronDown = lazy(() =>
+ import('lucide-react').then((mod) => ({ default: mod.ChevronDown }))
+);
+const ChevronUp = lazy(() =>
+ import('lucide-react').then((mod) => ({ default: mod.ChevronUp }))
+);
+
+/**
+ * Default maximum characters to display before truncation.
+ * Chosen to show approximately 2-3 lines of text in typical card widths.
+ */
+const DEFAULT_TRUNCATE_LENGTH = 150;
+
+/**
+ * Icon placeholder component for Suspense fallback.
+ * Renders an empty span with icon dimensions to prevent layout shift.
+ *
+ * @internal
+ */
+function IconPlaceholder({
+ className,
+}: {
+ className?: string;
+}): React.ReactElement {
+ return ;
+}
+
+/**
+ * Props for the SourceCard component.
+ *
+ * Extends standard HTML div attributes for flexibility in styling
+ * and event handling while requiring the core source data.
+ */
+export interface SourceCardProps
+ extends Omit, 'id'> {
+ /**
+ * The source object containing citation data from RAG retrieval.
+ * Includes heading path, page number, text excerpt, and optional score.
+ */
+ source: Source;
+
+ /**
+ * Whether to display the relevance score badge.
+ * When true, shows a subtle percentage indicator based on source.score.
+ *
+ * @default false
+ */
+ showScore?: boolean;
+
+ /**
+ * Maximum number of characters to display before truncation.
+ * Text exceeding this length will show a "Show more" button.
+ *
+ * @default 150
+ */
+ truncateLength?: number;
+
+ /**
+ * Initial expanded state for the text content.
+ * When true, the full text is shown by default.
+ *
+ * @default false
+ */
+ defaultExpanded?: boolean;
+}
+
+/**
+ * PageBadge Component
+ *
+ * Renders a small badge displaying the page number from the source document.
+ * Uses muted styling to be informative without dominating the visual hierarchy.
+ *
+ * @param page - The page number to display
+ * @returns Badge element with page number
+ *
+ * @internal
+ */
+function PageBadge({ page }: { page: number }): React.ReactElement {
+ return (
+
+ Page {page}
+
+ );
+}
+
+/**
+ * ScoreBadge Component
+ *
+ * Renders an optional relevance score as a percentage badge.
+ * Only visible when showScore prop is true and score exists.
+ * Uses color coding to indicate relevance level.
+ *
+ * @param score - Relevance score from 0-1 (converted to percentage)
+ * @returns Badge element with percentage, or null if no score
+ *
+ * @internal
+ */
+function ScoreBadge({
+ score,
+}: {
+ score: number | undefined;
+}): React.ReactElement | null {
+ /* Don't render if no score provided */
+ if (score === undefined) {
+ return null;
+ }
+
+ /* Convert 0-1 score to percentage for display */
+ const percentage = Math.round(score * 100);
+
+ /**
+ * Determine badge color based on relevance threshold:
+ * - High relevance (>= 80%): Success green
+ * - Medium relevance (>= 60%): Primary purple
+ * - Low relevance (< 60%): Muted gray
+ */
+ const colorClass =
+ percentage >= 80
+ ? 'text-[var(--success)] bg-[var(--success)]/10'
+ : percentage >= 60
+ ? 'text-[var(--color-primary-600)] bg-[var(--color-primary-100)]'
+ : 'text-[var(--foreground-muted)] bg-[var(--background-tertiary)]';
+
+ return (
+
+ {percentage}%
+
+ );
+}
+
+/**
+ * HeadingPath Component
+ *
+ * Renders the document hierarchy breadcrumb with a file icon.
+ * The heading path shows the navigation from document root to the
+ * specific section (e.g., "Chapter 1 > Section 2 > Subsection").
+ *
+ * @param headingPath - The formatted heading hierarchy string
+ * @returns Heading path element with icon
+ *
+ * @internal
+ */
+function HeadingPath({
+ headingPath,
+}: {
+ headingPath: string;
+}): React.ReactElement {
+ return (
+
+ {/* Document icon with Suspense for lazy loading */}
+ }>
+
+
+
+ {/* Heading path text */}
+
+ {headingPath}
+
+
+ );
+}
+
+/**
+ * ExpandableText Component
+ *
+ * Renders the source text content with expand/collapse functionality.
+ * Text is truncated by default and can be expanded via button click.
+ * Includes smooth height animation and proper accessibility attributes.
+ *
+ * @param text - The full source text content
+ * @param truncateLength - Maximum characters before truncation
+ * @param expanded - Current expanded state
+ * @param onToggle - Callback when expand/collapse is triggered
+ * @returns Expandable text section with toggle button
+ *
+ * @internal
+ */
+function ExpandableText({
+ text,
+ truncateLength,
+ expanded,
+ onToggle,
+}: {
+ text: string;
+ truncateLength: number;
+ expanded: boolean;
+ onToggle: () => void;
+}): React.ReactElement {
+ /**
+ * Determine if text needs truncation based on length.
+ * If text is shorter than truncateLength, no expand button is needed.
+ */
+ const needsTruncation = text.length > truncateLength;
+
+ /**
+ * Compute displayed text based on expansion state.
+ * Uses the truncateText utility for word-boundary-aware truncation.
+ */
+ const displayedText =
+ expanded || !needsTruncation ? text : truncateText(text, truncateLength);
+
+ /**
+ * Generate unique ID for the text region for aria-controls.
+ * Using a simple approach since each card instance is unique.
+ */
+ const textRegionId = 'source-text-region';
+
+ /**
+ * Handle keyboard interaction for accessibility.
+ * Supports Enter and Space keys for toggle activation.
+ */
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onToggle();
+ }
+ };
+
+ return (
+
+ {/* Text content region with smooth transition */}
+
+ {displayedText}
+
+
+ {/* Show more/less toggle button - only if truncation is needed */}
+ {needsTruncation && (
+
+ {/* Button label changes based on state */}
+ {expanded ? 'Show less' : 'Show more'}
+
+ {/* Chevron icon indicates expandable action */}
+ }>
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+}
+
+/**
+ * SourceCard Component
+ *
+ * A production-ready card component for displaying source citations
+ * from the RAG retrieval system. Presents document chunks with their
+ * metadata in an accessible, interactive format.
+ *
+ * @remarks
+ * ## Visual Design
+ * - Clean card layout with subtle background differentiation
+ * - Document icon with heading path breadcrumb
+ * - Page number badge in the header
+ * - Expandable text content with smooth animation
+ * - Optional relevance score indicator
+ *
+ * ## Accessibility Features
+ * - Proper ARIA labels on all interactive elements
+ * - `aria-expanded` attribute on expand/collapse button
+ * - `aria-controls` linking button to controlled region
+ * - Keyboard navigation support (Enter/Space to toggle)
+ * - Focus visible styles meeting WCAG 2.1 requirements
+ * - Semantic HTML structure (article element for grouping)
+ *
+ * ## Performance Optimizations
+ * - Lazy-loaded icons to reduce initial bundle size
+ * - React.memo wrapper to prevent unnecessary re-renders
+ * - Efficient state management with useState
+ *
+ * ## Color Contrast (WCAG AA)
+ * - Primary text: 15.7:1 on background-secondary (light mode)
+ * - Secondary text: 6.9:1 on background-secondary (light mode)
+ * - Muted text: 4.55:1 on background-secondary (light mode)
+ * - All interactions maintain required contrast ratios
+ *
+ * @param props - SourceCard component props
+ * @returns React element containing the styled source card
+ *
+ * @see {@link SourceCardProps} for full prop documentation
+ * @see {@link Source} for the source data structure
+ */
+const SourceCard = forwardRef(
+ (
+ {
+ source,
+ showScore = false,
+ truncateLength = DEFAULT_TRUNCATE_LENGTH,
+ defaultExpanded = false,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ /**
+ * Local state for tracking text expansion.
+ * Initialized from defaultExpanded prop.
+ */
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+ /**
+ * Memoized toggle handler to prevent unnecessary re-creations.
+ * Uses functional update pattern for state safety.
+ */
+ const handleToggle = useCallback(() => {
+ setIsExpanded((prev) => !prev);
+ }, []);
+
+ return (
+
+ {/* Header section: heading path + badges */}
+
+
+ {/* Divider line between header and content */}
+
+
+ {/* Content section: expandable text */}
+
+
+ );
+ }
+);
+
+/* Display name for React DevTools debugging */
+SourceCard.displayName = 'SourceCard';
+
+/**
+ * Memoized SourceCard for performance optimization.
+ *
+ * Since source cards are typically rendered in lists and their content
+ * rarely changes after initial render, memoization prevents unnecessary
+ * re-renders when sibling components update.
+ *
+ * The component re-renders when:
+ * - source object reference changes
+ * - showScore, truncateLength, or defaultExpanded props change
+ * - className or other HTML attributes change
+ */
+const MemoizedSourceCard = memo(SourceCard);
+
+/* Export both versions - use Memoized for lists, regular for single usage */
+export { SourceCard, MemoizedSourceCard };
diff --git a/frontend/src/components/chat/source-citations.tsx b/frontend/src/components/chat/source-citations.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..19dca7df9f869f59808d73171beb9b15f17aaa2c
--- /dev/null
+++ b/frontend/src/components/chat/source-citations.tsx
@@ -0,0 +1,427 @@
+/**
+ * SourceCitations Component
+ *
+ * A Claude-inspired minimal collapsible container component for displaying
+ * multiple source citations from the RAG retrieval system. Provides an
+ * organized, accessible way to present document references with smooth
+ * animations and a refined purple-themed aesthetic.
+ *
+ * Design Philosophy:
+ * - Follows Claude's minimal, clean interface patterns
+ * - Uses CSS custom properties that resolve to the purple theme palette
+ * - Subtle interactions with restrained hover states
+ * - Focus rings use purple accent (--border-focus) for brand consistency
+ *
+ * This component features:
+ * - Collapsible section with smooth height transitions
+ * - Minimal toggle button with subtle hover state
+ * - Source count indicator in the header
+ * - Staggered entrance animations for source cards
+ * - Full keyboard navigation and screen reader support
+ * - Responsive design with consistent spacing
+ *
+ * Theme Integration:
+ * - All colors reference CSS variables from globals.css
+ * - Purple focus ring via --border-focus (#a855f7 light / #c084fc dark)
+ * - Hover states use --background-tertiary for subtle feedback
+ * - Text uses --foreground hierarchy for visual depth
+ *
+ * @module components/chat/source-citations
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage with default collapsed state
+ *
+ *
+ * @example
+ * // Expanded by default with relevance scores
+ *
+ *
+ * @example
+ * // With custom styling
+ *
+ */
+
+'use client';
+
+import {
+ lazy,
+ memo,
+ Suspense,
+ useCallback,
+ useId,
+ useState,
+ type HTMLAttributes,
+ type KeyboardEvent,
+} from 'react';
+import { cn } from '@/lib/utils';
+import type { Source } from '@/types';
+import { MemoizedSourceCard } from './source-card';
+
+/**
+ * Lazy-loaded icons from lucide-react for bundle optimization.
+ *
+ * Icons are only loaded when the component is rendered, reducing
+ * the initial bundle size. This follows the project convention of
+ * lazy-loading heavy dependencies.
+ */
+const BookOpen = lazy(() =>
+ import('lucide-react').then((mod) => ({ default: mod.BookOpen }))
+);
+const ChevronDown = lazy(() =>
+ import('lucide-react').then((mod) => ({ default: mod.ChevronDown }))
+);
+
+/**
+ * Icon placeholder component for Suspense fallback.
+ * Renders an empty span with icon dimensions to prevent layout shift.
+ *
+ * @internal
+ */
+function IconPlaceholder({
+ className,
+}: {
+ className?: string;
+}): React.ReactElement {
+ return ;
+}
+
+/**
+ * Props for the SourceCitations component.
+ *
+ * Extends standard HTML div attributes for flexibility in styling
+ * and event handling while providing source-specific options.
+ *
+ * The component uses CSS custom properties for theming, which resolve
+ * to the purple color palette defined in globals.css.
+ */
+export interface SourceCitationsProps
+ extends Omit, 'id'> {
+ /**
+ * Array of source objects containing citation data from RAG retrieval.
+ * Each source includes heading path, page number, text excerpt, and optional score.
+ */
+ sources: Source[];
+
+ /**
+ * Initial expanded state of the citations section.
+ * When true, sources are visible by default.
+ *
+ * @default false
+ */
+ defaultExpanded?: boolean;
+
+ /**
+ * Whether to display relevance scores on individual source cards.
+ * When true, shows a percentage indicator based on source.score.
+ *
+ * @default false
+ */
+ showScores?: boolean;
+}
+
+/**
+ * SourceCitations Component
+ *
+ * A Claude-inspired minimal collapsible container for displaying source
+ * citations from the RAG retrieval system. Organizes multiple sources in
+ * an accessible, animated interface with proper semantic structure and
+ * a refined purple-themed aesthetic.
+ *
+ * @remarks
+ * ## Visual Design (Claude-Inspired Minimal)
+ * - Subtle top border separator using --border CSS variable
+ * - Minimal toggle button with restrained hover feedback
+ * - Header with muted icon and source count indicator
+ * - Smooth chevron rotation animation on toggle
+ * - Grid-based height animation for smooth expand/collapse
+ * - Staggered entrance animation for source cards
+ * - Consistent gap between source cards
+ *
+ * ## Purple Theme Integration
+ * - Focus rings use purple accent via --border-focus variable
+ * (Light: #a855f7 purple-500, Dark: #c084fc purple-400)
+ * - Hover states use --background-tertiary for subtle feedback
+ * - Text hierarchy uses --foreground, --foreground-secondary, --foreground-muted
+ * - All colors adapt automatically to light/dark mode
+ *
+ * ## Accessibility Features (WCAG 2.1 AA)
+ * - Semantic button element for toggle control
+ * - `aria-expanded` attribute on toggle button
+ * - `aria-controls` linking button to content region
+ * - `aria-label` describing the sources section purpose
+ * - `role="region"` on content with `aria-labelledby`
+ * - Full keyboard navigation (Enter/Space to toggle)
+ * - Purple focus ring meeting contrast requirements
+ * - Screen reader announcements for state changes
+ *
+ * ## Performance Optimizations
+ * - Lazy-loaded icons to reduce initial bundle size
+ * - React.memo wrapper to prevent unnecessary re-renders
+ * - useCallback for stable handler references
+ * - Memoized SourceCard components for list efficiency
+ *
+ * ## Animation Approach
+ * - CSS Grid with `grid-rows-[0fr]` to `grid-rows-[1fr]` transition
+ * - Transform rotate for chevron icon (180deg on expand)
+ * - Staggered animation delays for source cards using inline styles
+ * - Smooth transitions using CSS custom property durations
+ *
+ * @param props - SourceCitations component props
+ * @returns React element containing the styled collapsible citations container
+ *
+ * @see {@link SourceCitationsProps} for full prop documentation
+ * @see {@link Source} for the source data structure
+ * @see {@link MemoizedSourceCard} for individual source rendering
+ */
+const SourceCitations = ({
+ sources,
+ defaultExpanded = false,
+ showScores = false,
+ className,
+ ...props
+}: SourceCitationsProps): React.ReactElement | null => {
+ /**
+ * Generate unique IDs for accessibility attributes.
+ * Using useId hook ensures uniqueness across multiple instances.
+ */
+ const uniqueId = useId();
+ const buttonId = `${uniqueId}-toggle`;
+ const regionId = `${uniqueId}-content`;
+
+ /**
+ * Local state for tracking expansion.
+ * Initialized from defaultExpanded prop.
+ */
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+ /**
+ * Memoized toggle handler to prevent unnecessary re-creations.
+ * Uses functional update pattern for state safety.
+ */
+ const handleToggle = useCallback(() => {
+ setIsExpanded((prev) => !prev);
+ }, []);
+
+ /**
+ * Handle keyboard interaction for accessibility.
+ * Supports Enter and Space keys for toggle activation.
+ */
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent): void => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleToggle();
+ }
+ },
+ [handleToggle]
+ );
+
+ /* Don't render if no sources provided */
+ if (!sources || sources.length === 0) {
+ return null;
+ }
+
+ /**
+ * Determine the label text based on source count.
+ * Uses singular/plural form for proper grammar.
+ */
+ const sourceCountLabel =
+ sources.length === 1 ? '1 Source' : `${sources.length} Sources`;
+
+ return (
+
+ {/* Minimal Toggle Button - Claude-inspired subtle design */}
+
+ {/* Left side: Icon and source count - minimal styling */}
+
+ {/* BookOpen icon with Suspense for lazy loading */}
+ }>
+
+
+
+ {/* Source count label - secondary color for subtlety */}
+
+ {sourceCountLabel}
+
+
+
+ {/* Right side: Chevron indicator with smooth rotation animation */}
+ }>
+
+
+
+
+ {/* Collapsible Content Region - smooth grid-based animation */}
+
+ {/* Inner wrapper required for grid animation to work properly */}
+
+ {/* Source cards container - minimal spacing */}
+
+ {/* Render source cards with staggered entrance animation */}
+ {sources.map((source, index) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+/* Display name for React DevTools debugging */
+SourceCitations.displayName = 'SourceCitations';
+
+/**
+ * Memoized SourceCitations for performance optimization.
+ *
+ * Since source citations are typically rendered within messages and their
+ * content rarely changes after initial render, memoization prevents
+ * unnecessary re-renders when sibling components update.
+ *
+ * The memoized version uses the same purple-themed CSS variables and
+ * Claude-inspired minimal design as the base component. All theme
+ * colors are inherited through CSS custom properties.
+ *
+ * The component re-renders when:
+ * - sources array reference changes
+ * - defaultExpanded, showScores props change
+ * - className or other HTML attributes change
+ */
+const MemoizedSourceCitations = memo(SourceCitations);
+
+/* Export both versions - use Memoized for typical usage, regular for testing */
+export { SourceCitations, MemoizedSourceCitations };
diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..624544c64427c66b7f37c60e65d48ac791c49495
--- /dev/null
+++ b/frontend/src/components/layout/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Layout Components Barrel Export
+ *
+ * Re-exports all layout-related components for convenient imports.
+ *
+ * @example
+ * import { Header, Footer, Container } from '@/components/layout';
+ */
+
+// Layout components to be exported:
+// export * from './header';
+// export * from './footer';
+// export * from './container';
diff --git a/frontend/src/components/providers/index.ts b/frontend/src/components/providers/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d56a7e6cd937051a3f3ff914532a4752dde50cb9
--- /dev/null
+++ b/frontend/src/components/providers/index.ts
@@ -0,0 +1,27 @@
+/**
+ * Providers Barrel Export
+ *
+ * Central export point for all React context providers used in the application.
+ * Import providers from this module for cleaner imports.
+ *
+ * @module components/providers
+ * @since 1.0.0
+ *
+ * @example
+ * // Import specific providers
+ * import { ThemeProvider, useTheme } from '@/components/providers';
+ *
+ * @example
+ * // Import types
+ * import type { Theme, ThemeContextValue } from '@/components/providers';
+ */
+
+// Theme Provider - manages light/dark/system theme
+export {
+ ThemeProvider,
+ useTheme,
+ type Theme,
+ type ResolvedTheme,
+ type ThemeContextValue,
+ type ThemeProviderProps,
+} from './theme-provider';
diff --git a/frontend/src/components/providers/theme-provider.tsx b/frontend/src/components/providers/theme-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7f1731b6b3321d7a71c68dd334f297d7f1913957
--- /dev/null
+++ b/frontend/src/components/providers/theme-provider.tsx
@@ -0,0 +1,428 @@
+'use client';
+
+/**
+ * Theme Provider Component
+ *
+ * Provides theme context and management for the application, supporting
+ * three theme modes: 'light', 'dark', and 'system' (follows OS preference).
+ *
+ * Features:
+ * - Persists user preference to localStorage
+ * - Applies correct class to document.documentElement for CSS theming
+ * - Avoids hydration mismatch by deferring DOM updates to client
+ * - Listens to system preference changes when in 'system' mode
+ *
+ * @module components/providers/theme-provider
+ * @since 1.0.0
+ *
+ * @example
+ * // Wrap your app with ThemeProvider in layout.tsx
+ * import { ThemeProvider } from '@/components/providers/theme-provider';
+ *
+ * export default function RootLayout({ children }) {
+ * return (
+ *
+ *
+ *
+ * {children}
+ *
+ *
+ *
+ * );
+ * }
+ *
+ * @example
+ * // Use the useTheme hook in components
+ * import { useTheme } from '@/components/providers/theme-provider';
+ *
+ * function ThemeToggle() {
+ * const { theme, setTheme, resolvedTheme } = useTheme();
+ *
+ * return (
+ * setTheme(theme === 'dark' ? 'light' : 'dark')}>
+ * Current theme: {resolvedTheme}
+ *
+ * );
+ * }
+ */
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+/**
+ * Theme values that can be set by the user.
+ *
+ * @property light - Force light mode regardless of system preference
+ * @property dark - Force dark mode regardless of system preference
+ * @property system - Follow the operating system's color scheme preference
+ */
+export type Theme = 'light' | 'dark' | 'system';
+
+/**
+ * Resolved theme values (excludes 'system' as it resolves to light or dark).
+ *
+ * When theme is 'system', this will be either 'light' or 'dark' based on
+ * the user's operating system preference.
+ */
+export type ResolvedTheme = 'light' | 'dark';
+
+/**
+ * Theme context value interface.
+ *
+ * Provides theme state and controls to consuming components.
+ */
+export interface ThemeContextValue {
+ /**
+ * The current theme setting ('light', 'dark', or 'system').
+ * This is the user's selected preference.
+ */
+ theme: Theme;
+
+ /**
+ * The actual resolved theme ('light' or 'dark').
+ * When theme is 'system', this reflects the OS preference.
+ * Useful for conditionally rendering theme-dependent UI.
+ */
+ resolvedTheme: ResolvedTheme;
+
+ /**
+ * Function to update the theme preference.
+ * The new theme is persisted to localStorage and applied immediately.
+ *
+ * @param theme - The theme to set ('light', 'dark', or 'system')
+ */
+ setTheme: (theme: Theme) => void;
+
+ /**
+ * Indicates whether the theme has been loaded from storage.
+ * Use this to prevent flash of incorrect theme on initial render.
+ *
+ * @remarks
+ * Will be false during SSR and initial hydration, true once
+ * the theme has been read from localStorage and applied.
+ */
+ isLoaded: boolean;
+}
+
+/**
+ * Props for the ThemeProvider component.
+ */
+export interface ThemeProviderProps {
+ /**
+ * Child components that will have access to theme context.
+ */
+ children: ReactNode;
+
+ /**
+ * Default theme to use when no preference is stored.
+ * Recommended to use 'system' for best user experience.
+ *
+ * @default 'system'
+ */
+ defaultTheme?: Theme;
+
+ /**
+ * localStorage key for persisting theme preference.
+ * Change this if you need to avoid conflicts with other apps.
+ *
+ * @default 'theme'
+ */
+ storageKey?: string;
+}
+
+/**
+ * React context for theme state.
+ *
+ * @internal
+ */
+const ThemeContext = createContext(undefined);
+
+/**
+ * Gets the system color scheme preference.
+ *
+ * @returns 'dark' if system prefers dark mode, 'light' otherwise
+ *
+ * @internal
+ */
+function getSystemTheme(): ResolvedTheme {
+ if (typeof window === 'undefined') {
+ return 'light';
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light';
+}
+
+/**
+ * Resolves the effective theme from the user's selection.
+ *
+ * @param theme - User's theme selection
+ * @returns Resolved theme ('light' or 'dark')
+ *
+ * @internal
+ */
+function resolveTheme(theme: Theme): ResolvedTheme {
+ if (theme === 'system') {
+ return getSystemTheme();
+ }
+ return theme;
+}
+
+/**
+ * Applies the theme class to the document root element.
+ *
+ * Removes any existing theme classes and applies the appropriate one:
+ * - For 'system' theme: removes both classes (CSS handles via media query)
+ * - For 'light' or 'dark': applies the corresponding class
+ *
+ * @param theme - Theme to apply
+ *
+ * @internal
+ */
+function applyThemeToDocument(theme: Theme): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const root = document.documentElement;
+
+ // Remove existing theme classes
+ root.classList.remove('light', 'dark');
+
+ // Apply appropriate class based on theme setting
+ if (theme === 'light' || theme === 'dark') {
+ root.classList.add(theme);
+ }
+ // When theme is 'system', we don't add any class
+ // The CSS media query will handle it
+}
+
+/**
+ * Theme Provider Component
+ *
+ * Manages theme state, persistence, and application. Wraps the application
+ * to provide theme context to all child components.
+ *
+ * @remarks
+ * ## Implementation Details
+ *
+ * ### Hydration Safety
+ * The provider uses a two-phase initialization:
+ * 1. Server/initial render uses defaultTheme
+ * 2. Client effect reads localStorage and syncs state
+ *
+ * This prevents hydration mismatches while ensuring the correct
+ * theme is applied as quickly as possible.
+ *
+ * ### System Theme Tracking
+ * When theme is set to 'system', the provider listens to the
+ * `prefers-color-scheme` media query and updates resolvedTheme
+ * automatically when the OS preference changes.
+ *
+ * ### CSS Integration
+ * Works with the CSS custom properties defined in globals.css:
+ * - `.dark` class forces dark mode
+ * - `.light` class forces light mode
+ * - No class follows system preference via `@media (prefers-color-scheme)`
+ *
+ * @param props - ThemeProvider props
+ * @returns Provider component wrapping children
+ *
+ * @see {@link ThemeProviderProps} for available props
+ * @see {@link useTheme} for consuming theme in components
+ */
+export function ThemeProvider({
+ children,
+ defaultTheme = 'system',
+ storageKey = 'theme',
+}: ThemeProviderProps): ReactNode {
+ // Track the user's theme preference
+ const [theme, setThemeState] = useState(defaultTheme);
+
+ // Track the resolved theme (what's actually displayed)
+ const [resolvedTheme, setResolvedTheme] = useState(() =>
+ resolveTheme(defaultTheme)
+ );
+
+ // Track whether we've loaded the theme from storage (client-side)
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ /**
+ * Set theme with persistence and DOM update.
+ *
+ * Updates state, persists to localStorage, and applies class to document.
+ */
+ const setTheme = useCallback(
+ (newTheme: Theme): void => {
+ setThemeState(newTheme);
+ setResolvedTheme(resolveTheme(newTheme));
+
+ // Persist to localStorage
+ try {
+ localStorage.setItem(storageKey, newTheme);
+ } catch (error) {
+ // localStorage may be unavailable (private browsing, storage full, etc.)
+ console.warn('Failed to persist theme to localStorage:', error);
+ }
+
+ // Apply to document
+ applyThemeToDocument(newTheme);
+ },
+ [storageKey]
+ );
+
+ /**
+ * Effect: Initialize theme from localStorage on mount.
+ *
+ * This runs only on the client after hydration, preventing
+ * hydration mismatches while ensuring quick theme application.
+ *
+ * Uses requestAnimationFrame to defer state updates to the next frame,
+ * avoiding synchronous setState calls within the effect body.
+ */
+ useEffect(() => {
+ let cancelled = false;
+
+ const initializeTheme = (): void => {
+ if (cancelled) return;
+
+ try {
+ const stored = localStorage.getItem(storageKey);
+ if (stored && ['light', 'dark', 'system'].includes(stored)) {
+ const storedTheme = stored as Theme;
+ setThemeState(storedTheme);
+ setResolvedTheme(resolveTheme(storedTheme));
+ applyThemeToDocument(storedTheme);
+ } else {
+ // No valid stored theme, apply default
+ applyThemeToDocument(defaultTheme);
+ }
+ } catch (error) {
+ // localStorage unavailable, apply default
+ console.warn('Failed to read theme from localStorage:', error);
+ applyThemeToDocument(defaultTheme);
+ }
+
+ setIsLoaded(true);
+ };
+
+ // Defer state updates to next frame to avoid synchronous setState in effect
+ const frameId = requestAnimationFrame(initializeTheme);
+
+ return () => {
+ cancelled = true;
+ cancelAnimationFrame(frameId);
+ };
+ }, [defaultTheme, storageKey]);
+
+ /**
+ * Effect: Listen for system theme changes when in 'system' mode.
+ *
+ * Automatically updates resolvedTheme when the user's OS preference changes.
+ */
+ useEffect(() => {
+ if (theme !== 'system') {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const handleChange = (event: MediaQueryListEvent): void => {
+ setResolvedTheme(event.matches ? 'dark' : 'light');
+ };
+
+ // Modern browsers
+ mediaQuery.addEventListener('change', handleChange);
+
+ return () => {
+ mediaQuery.removeEventListener('change', handleChange);
+ };
+ }, [theme]);
+
+ /**
+ * Memoized context value to prevent unnecessary re-renders.
+ */
+ const contextValue = useMemo(
+ () => ({
+ theme,
+ resolvedTheme,
+ setTheme,
+ isLoaded,
+ }),
+ [theme, resolvedTheme, setTheme, isLoaded]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook to access theme context.
+ *
+ * Provides access to the current theme, resolved theme, and setTheme function.
+ * Must be used within a ThemeProvider.
+ *
+ * @returns Theme context value with theme state and controls
+ * @throws Error if used outside of ThemeProvider
+ *
+ * @example
+ * // Basic usage
+ * function MyComponent() {
+ * const { theme, setTheme } = useTheme();
+ *
+ * return (
+ * setTheme(e.target.value as Theme)}>
+ * Light
+ * Dark
+ * System
+ *
+ * );
+ * }
+ *
+ * @example
+ * // Conditional rendering based on resolved theme
+ * function Logo() {
+ * const { resolvedTheme } = useTheme();
+ *
+ * return (
+ *
+ * );
+ * }
+ *
+ * @example
+ * // Handling loading state
+ * function ThemeToggle() {
+ * const { isLoaded, resolvedTheme } = useTheme();
+ *
+ * if (!isLoaded) {
+ * return
;
+ * }
+ *
+ * return Current theme: {resolvedTheme} ;
+ * }
+ */
+export function useTheme(): ThemeContextValue {
+ const context = useContext(ThemeContext);
+
+ if (context === undefined) {
+ throw new Error(
+ 'useTheme must be used within a ThemeProvider. ' +
+ 'Wrap your application with to use the useTheme hook.'
+ );
+ }
+
+ return context;
+}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d9334d6c9b30e24b69ed9309b2e7da1e6517179
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,346 @@
+/**
+ * Button Component
+ *
+ * A production-ready, accessible button component with multiple variants,
+ * sizes, and states. Built with class-variance-authority for type-safe
+ * variant management and designed to meet WCAG AA accessibility standards.
+ *
+ * @module components/ui/button
+ * @since 1.0.0
+ *
+ * @example
+ * // Primary button (default)
+ * Click me
+ *
+ * @example
+ * // Secondary button with large size
+ * Submit
+ *
+ * @example
+ * // Loading state
+ * Submit
+ *
+ * @example
+ * // As a link using asChild pattern
+ *
+ * Go to Dashboard
+ *
+ *
+ * @example
+ * // Icon button
+ *
+ *
+ *
+ */
+
+import { type VariantProps, cva } from 'class-variance-authority';
+import {
+ forwardRef,
+ type ButtonHTMLAttributes,
+ type ReactElement,
+ type ReactNode,
+ isValidElement,
+ cloneElement,
+} from 'react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Button variant styles using class-variance-authority.
+ *
+ * Defines the visual appearance variations for the Button component.
+ * All variants are designed to meet WCAG AA contrast requirements.
+ *
+ * @remarks
+ * - Uses CSS custom properties from globals.css for consistent theming
+ * - Supports dark mode through CSS variable overrides
+ * - Focus states use the design system's focus ring
+ */
+const buttonVariants = cva(
+ /* Base styles applied to all buttons */
+ [
+ 'inline-flex items-center justify-center gap-2',
+ 'font-medium whitespace-nowrap',
+ 'rounded-[var(--radius-md)]',
+ 'transition-all duration-[var(--transition-fast)]',
+ 'focus-visible:outline-none focus-visible:ring-2',
+ 'focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2',
+ 'focus-visible:ring-offset-[var(--background)]',
+ 'disabled:pointer-events-none disabled:opacity-50',
+ 'select-none',
+ ],
+ {
+ variants: {
+ /**
+ * Visual style variants for the button.
+ *
+ * @property primary - Main call-to-action. Purple background with white text.
+ * Use for primary actions like "Submit", "Save", "Continue".
+ * Contrast ratio: 4.6:1 against white background.
+ *
+ * @property secondary - Secondary actions. Muted background with dark text.
+ * Use for "Cancel", "Back", or secondary options.
+ *
+ * @property outline - Bordered button with transparent background.
+ * Use when you need a button that doesn't compete with primary.
+ *
+ * @property ghost - Minimal button with no background or border.
+ * Use for icon buttons, toolbar actions, or tertiary actions.
+ *
+ * @property destructive - Danger/warning actions. Red background.
+ * Use for "Delete", "Remove", or irreversible actions.
+ */
+ variant: {
+ primary: [
+ 'bg-[var(--color-primary-500)] text-white',
+ 'hover:bg-[var(--color-primary-600)]',
+ 'active:bg-[var(--color-primary-700)]',
+ 'shadow-[var(--shadow-sm)]',
+ 'hover:shadow-[var(--shadow-md)]',
+ ],
+ secondary: [
+ 'bg-[var(--background-tertiary)] text-[var(--foreground)]',
+ 'hover:bg-[var(--border)]',
+ 'active:bg-[var(--background-secondary)]',
+ ],
+ outline: [
+ 'border border-[var(--border)]',
+ 'bg-transparent text-[var(--foreground)]',
+ 'hover:bg-[var(--background-secondary)]',
+ 'hover:border-[var(--foreground-muted)]',
+ 'active:bg-[var(--background-tertiary)]',
+ ],
+ ghost: [
+ 'bg-transparent text-[var(--foreground)]',
+ 'hover:bg-[var(--background-secondary)]',
+ 'active:bg-[var(--background-tertiary)]',
+ ],
+ destructive: [
+ 'bg-[var(--error)] text-white',
+ 'hover:bg-[#dc2626]' /* Darker red for hover - maintains 4.5:1 contrast */,
+ 'active:bg-[#b91c1c]',
+ 'shadow-[var(--shadow-sm)]',
+ 'hover:shadow-[var(--shadow-md)]',
+ ],
+ },
+ /**
+ * Size variants controlling padding, font size, and dimensions.
+ *
+ * @property sm - Small button (28px height). For compact UIs and inline actions.
+ * @property md - Medium button (36px height). Default size for most use cases.
+ * @property lg - Large button (44px height). For prominent CTAs and touch targets.
+ * @property icon - Square button for icon-only actions. Maintains 1:1 aspect ratio.
+ */
+ size: {
+ sm: 'h-7 px-3 text-xs',
+ md: 'h-9 px-4 text-sm',
+ lg: 'h-11 px-6 text-base',
+ icon: 'h-9 w-9 p-0',
+ },
+ },
+ defaultVariants: {
+ variant: 'primary',
+ size: 'md',
+ },
+ }
+);
+
+/**
+ * Loading spinner component for the button's loading state.
+ *
+ * @param className - Additional CSS classes to apply
+ * @returns SVG spinner element with animation
+ *
+ * @internal
+ */
+function LoadingSpinner({ className }: { className?: string }): ReactElement {
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * Props for the Button component.
+ *
+ * Extends standard HTML button attributes with additional
+ * variant, size, loading, and composition options.
+ */
+export interface ButtonProps
+ extends
+ ButtonHTMLAttributes,
+ VariantProps {
+ /**
+ * When true, the component renders its child element with merged props
+ * instead of a button element. Useful for rendering links styled as buttons.
+ *
+ * @default false
+ *
+ * @example
+ *
+ * Go Home
+ *
+ */
+ asChild?: boolean;
+
+ /**
+ * Shows a loading spinner and optionally replaces the button text.
+ * Also sets aria-busy="true" for accessibility.
+ *
+ * @default false
+ */
+ isLoading?: boolean;
+
+ /**
+ * Text to display when isLoading is true. If not provided,
+ * the original children are preserved alongside the spinner.
+ */
+ loadingText?: string;
+
+ /**
+ * Position of the loading spinner relative to text.
+ * Only applies when isLoading is true.
+ *
+ * @default 'left'
+ */
+ spinnerPosition?: 'left' | 'right';
+
+ /**
+ * The content to render inside the button.
+ */
+ children?: ReactNode;
+}
+
+/**
+ * Button component with multiple variants, sizes, and states.
+ *
+ * A versatile button component that supports various visual styles,
+ * loading states, and can render as different elements using the
+ * asChild composition pattern.
+ *
+ * @remarks
+ * ## Accessibility Features
+ * - Proper focus management with visible focus ring
+ * - aria-busy attribute during loading states
+ * - aria-disabled for disabled buttons
+ * - Supports aria-label for icon buttons
+ *
+ * ## Color Contrast (WCAG AA)
+ * - Primary: Purple-500 with white text = 4.6:1 contrast ratio
+ * - Destructive: #ef4444 on white = 4.5:1 contrast ratio
+ * - All text colors meet 4.5:1 minimum contrast
+ *
+ * @param props - Button component props
+ * @returns React button element or cloned child element
+ *
+ * @see {@link ButtonProps} for full prop documentation
+ * @see {@link buttonVariants} for available style variants
+ */
+const Button = forwardRef(
+ (
+ {
+ className,
+ variant,
+ size,
+ asChild = false,
+ isLoading = false,
+ loadingText,
+ spinnerPosition = 'left',
+ disabled,
+ children,
+ type = 'button',
+ ...props
+ },
+ ref
+ ) => {
+ /* Determine if button should be disabled (explicit disabled or loading) */
+ const isDisabled = disabled || isLoading;
+
+ /* Compute spinner size based on button size variant */
+ const spinnerSize =
+ size === 'sm' ? 'h-3 w-3' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4';
+
+ /* Build the button content with optional loading spinner */
+ const renderContent = (): ReactNode => {
+ if (isLoading) {
+ const spinner = ;
+ const text = loadingText ?? children;
+
+ if (spinnerPosition === 'right') {
+ return (
+ <>
+ {text}
+ {spinner}
+ >
+ );
+ }
+
+ return (
+ <>
+ {spinner}
+ {text}
+ >
+ );
+ }
+
+ return children;
+ };
+
+ /* Shared props for both button element and asChild clone */
+ const sharedProps = {
+ className: cn(buttonVariants({ variant, size }), className),
+ disabled: isDisabled,
+ 'aria-busy': isLoading ? true : undefined,
+ 'aria-disabled': isDisabled ? true : undefined,
+ ...props,
+ };
+
+ /*
+ * asChild pattern: Clone the child element with button props merged.
+ * This allows rendering links or other elements with button styling.
+ *
+ * Note: We pass the ref directly to cloneElement which forwards it to the
+ * child. This is the standard pattern for Radix-style asChild composition.
+ * The ref is not read during render - it's passed through for React to
+ * attach after mounting.
+ */
+ if (asChild && isValidElement(children)) {
+ // eslint-disable-next-line react-hooks/refs
+ return cloneElement(children as ReactElement>, {
+ ...sharedProps,
+ ref,
+ });
+ }
+
+ return (
+
+ {renderContent()}
+
+ );
+ }
+);
+
+/* Display name for React DevTools */
+Button.displayName = 'Button';
+
+/* Export component and variant types */
+export { Button, buttonVariants };
+export type { VariantProps };
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4f09ece84d38316a0ced7dae8f46f9be7cf04187
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,443 @@
+/**
+ * Card Component
+ *
+ * A production-ready, accessible card component using the compound component
+ * pattern. Built with class-variance-authority for type-safe variant management
+ * and designed to meet WCAG AA accessibility standards.
+ *
+ * The Card component provides a flexible container for grouping related content
+ * with consistent styling, supporting multiple visual variants for different
+ * use cases.
+ *
+ * @module components/ui/card
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic card with header and content
+ *
+ *
+ * Card Title
+ * Optional description text
+ *
+ *
+ * Card content goes here
+ *
+ *
+ *
+ * @example
+ * // Interactive card with footer
+ *
+ *
+ * Interactive Card
+ *
+ *
+ * Hover to see the effect
+ *
+ *
+ * Action
+ *
+ *
+ *
+ * @example
+ * // Elevated card variant
+ *
+ *
+ * Elevated card with shadow
+ *
+ *
+ */
+
+import { type VariantProps, cva } from 'class-variance-authority';
+import { forwardRef, type HTMLAttributes } from 'react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Card variant styles using class-variance-authority.
+ *
+ * Defines the visual appearance variations for the Card component.
+ * All variants are designed to meet WCAG AA contrast requirements.
+ *
+ * @remarks
+ * - Uses CSS custom properties from globals.css for consistent theming
+ * - Supports dark mode through CSS variable overrides
+ * - Focus states are only applied to interactive variant
+ * - All color combinations maintain 4.5:1 minimum contrast ratio
+ */
+const cardVariants = cva(
+ /* Base styles applied to all cards */
+ [
+ 'rounded-[var(--radius-lg)]',
+ 'bg-[var(--background)]',
+ 'text-[var(--foreground)]',
+ ],
+ {
+ variants: {
+ /**
+ * Visual style variants for the card.
+ *
+ * @property default - Basic card with subtle border. Minimal visual weight.
+ * Use for standard content containers.
+ *
+ * @property bordered - Card with visible border emphasis. Use when you need
+ * clear visual separation between cards.
+ *
+ * @property elevated - Card with shadow for visual hierarchy. Use for
+ * content that should appear "above" the page.
+ * Suitable for modals, popovers, or featured content.
+ *
+ * @property interactive - Card with hover and focus states. Use for
+ * clickable cards like navigation items or
+ * selectable options. Includes cursor pointer
+ * and transform effects on hover.
+ */
+ variant: {
+ default: ['border border-[var(--border)]'],
+ bordered: ['border-2 border-[var(--border)]'],
+ elevated: [
+ 'border border-[var(--border)]',
+ 'shadow-[var(--shadow-md)]',
+ ],
+ interactive: [
+ 'border border-[var(--border)]',
+ 'shadow-[var(--shadow-sm)]',
+ 'cursor-pointer',
+ 'transition-all duration-[var(--transition-normal)]',
+ 'hover:shadow-[var(--shadow-md)]',
+ 'hover:border-[var(--foreground-muted)]',
+ 'hover:-translate-y-0.5',
+ 'focus-visible:outline-none',
+ 'focus-visible:ring-2',
+ 'focus-visible:ring-[var(--border-focus)]',
+ 'focus-visible:ring-offset-2',
+ 'focus-visible:ring-offset-[var(--background)]',
+ 'active:translate-y-0',
+ 'active:shadow-[var(--shadow-sm)]',
+ ],
+ },
+ /**
+ * Padding variants for responsive spacing.
+ *
+ * @property none - No padding. Use when child components handle their own padding.
+ * @property sm - Small padding (12px). For compact cards.
+ * @property md - Medium padding (16px). Default size for most use cases.
+ * @property lg - Large padding (24px). For spacious layouts.
+ * @property responsive - Responsive padding that scales with viewport.
+ * 12px on mobile, 16px on tablet, 24px on desktop.
+ */
+ padding: {
+ none: '',
+ sm: 'p-3',
+ md: 'p-4',
+ lg: 'p-6',
+ responsive: 'p-3 sm:p-4 lg:p-6',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ padding: 'none',
+ },
+ }
+);
+
+/**
+ * Props for the Card component.
+ *
+ * Extends standard HTML div attributes with additional
+ * variant and padding options for customization.
+ */
+export interface CardProps
+ extends HTMLAttributes, VariantProps {}
+
+/**
+ * Card component - the main container for card content.
+ *
+ * A versatile container component that groups related content with
+ * consistent styling. Supports multiple visual variants and padding
+ * options for different use cases.
+ *
+ * @remarks
+ * ## Accessibility Features
+ * - Semantic HTML structure using div elements
+ * - Interactive variant includes proper focus management
+ * - Focus ring meets WCAG 2.1 focus visibility requirements
+ * - Color contrast meets WCAG AA requirements (4.5:1 minimum)
+ *
+ * ## Color Contrast (WCAG AA)
+ * - Light mode: #0f172a text on #ffffff background = 15.7:1 contrast
+ * - Dark mode: #f8fafc text on #0f172a background = 15.7:1 contrast
+ * - Border colors provide sufficient visual distinction
+ *
+ * ## Interactive Variant
+ * When using the interactive variant, consider adding:
+ * - `role="button"` or wrapping in a link/button for keyboard access
+ * - `tabIndex={0}` for keyboard focus
+ * - `onClick` handler for click interactions
+ * - `onKeyDown` handler for Enter/Space key activation
+ *
+ * @param props - Card component props including variant and padding
+ * @returns React div element with card styling
+ *
+ * @see {@link CardProps} for full prop documentation
+ * @see {@link cardVariants} for available style variants
+ */
+const Card = forwardRef(
+ ({ className, variant, padding, ...props }, ref) => (
+
+ )
+);
+
+Card.displayName = 'Card';
+
+/**
+ * Props for the CardHeader component.
+ *
+ * Extends standard HTML div attributes for full customization.
+ */
+export type CardHeaderProps = HTMLAttributes;
+
+/**
+ * CardHeader component - container for card title and description.
+ *
+ * Provides consistent spacing and layout for the card's header section.
+ * Typically contains CardTitle and optionally CardDescription components.
+ *
+ * @remarks
+ * - Uses flexbox column layout for proper stacking
+ * - Includes bottom padding to separate from content
+ * - Gap between title and description is handled automatically
+ *
+ * @example
+ *
+ * Account Settings
+ * Manage your account preferences
+ *
+ *
+ * @param props - Standard HTML div attributes
+ * @returns React div element with header styling
+ */
+const CardHeader = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+CardHeader.displayName = 'CardHeader';
+
+/**
+ * Props for the CardTitle component.
+ *
+ * Extends standard HTML heading attributes for full customization.
+ */
+export type CardTitleProps = HTMLAttributes;
+
+/**
+ * CardTitle component - the primary heading for a card.
+ *
+ * Renders as an h3 element by default for proper document outline.
+ * Styled with appropriate font weight and size for visual hierarchy.
+ *
+ * @remarks
+ * ## Accessibility
+ * - Renders as h3 for semantic document structure
+ * - Use appropriate heading level in context (h2, h4, etc.)
+ * by passing as="h2" in className or using a different element
+ * - Text color meets WCAG AA contrast requirements
+ *
+ * ## Typography
+ * - Uses semibold weight for emphasis
+ * - Leading (line-height) is tight for compact appearance
+ * - Letter spacing is slightly tightened for headings
+ *
+ * @example
+ * Dashboard Overview
+ *
+ * @param props - Standard HTML heading attributes
+ * @returns React h3 element with title styling
+ */
+const CardTitle = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+CardTitle.displayName = 'CardTitle';
+
+/**
+ * Props for the CardDescription component.
+ *
+ * Extends standard HTML paragraph attributes for full customization.
+ */
+export type CardDescriptionProps = HTMLAttributes;
+
+/**
+ * CardDescription component - secondary text for card context.
+ *
+ * Provides supplementary information about the card's content.
+ * Uses muted text color to establish visual hierarchy below the title.
+ *
+ * @remarks
+ * ## Accessibility
+ * - Uses paragraph element for semantic structure
+ * - Muted color still meets WCAG AA contrast (4.5:1 minimum)
+ * - Light mode: #475569 on #ffffff = 6.9:1 contrast
+ * - Dark mode: #cbd5e1 on #0f172a = 9.5:1 contrast
+ *
+ * ## Typography
+ * - Uses smaller font size than title
+ * - Muted color for visual hierarchy
+ * - Default line height for readability
+ *
+ * @example
+ *
+ * View your recent activity and manage settings
+ *
+ *
+ * @param props - Standard HTML paragraph attributes
+ * @returns React p element with description styling
+ */
+const CardDescription = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+CardDescription.displayName = 'CardDescription';
+
+/**
+ * Props for the CardContent component.
+ *
+ * Extends standard HTML div attributes for full customization.
+ */
+export type CardContentProps = HTMLAttributes;
+
+/**
+ * CardContent component - main content area of the card.
+ *
+ * Contains the primary content of the card with consistent padding.
+ * Flexible container that can hold any content type.
+ *
+ * @remarks
+ * - Responsive padding: 16px on mobile, 24px on larger screens
+ * - Top padding is reduced when following CardHeader
+ * - Use className to adjust padding as needed
+ *
+ * @example
+ *
+ * Your main content goes here.
+ *
+ * List item 1
+ * List item 2
+ *
+ *
+ *
+ * @param props - Standard HTML div attributes
+ * @returns React div element with content styling
+ */
+const CardContent = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+CardContent.displayName = 'CardContent';
+
+/**
+ * Props for the CardFooter component.
+ *
+ * Extends standard HTML div attributes for full customization.
+ */
+export type CardFooterProps = HTMLAttributes;
+
+/**
+ * CardFooter component - action area at the bottom of the card.
+ *
+ * Typically contains buttons, links, or other interactive elements.
+ * Uses flexbox for easy alignment of multiple actions.
+ *
+ * @remarks
+ * - Flexbox layout with items centered vertically
+ * - Gap between items for consistent spacing
+ * - Border-top can be added via className for visual separation
+ * - Responsive padding matches other card sections
+ *
+ * @example
+ * // Single action
+ *
+ * Save Changes
+ *
+ *
+ * @example
+ * // Multiple actions with custom alignment
+ *
+ * Cancel
+ * Confirm
+ *
+ *
+ * @example
+ * // With separator border
+ *
+ * Submit
+ *
+ *
+ * @param props - Standard HTML div attributes
+ * @returns React div element with footer styling
+ */
+const CardFooter = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+CardFooter.displayName = 'CardFooter';
+
+/* Export all components and types */
+export {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ cardVariants,
+};
+
+/* Export variant types for external use */
+export type { VariantProps };
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d3dbcf42b48ffa141113161567143ab6ca610bcb
--- /dev/null
+++ b/frontend/src/components/ui/index.ts
@@ -0,0 +1,88 @@
+/**
+ * UI Components Barrel Export
+ *
+ * Re-exports all base UI components for convenient imports.
+ * This module provides a centralized entry point for importing
+ * UI primitives throughout the application.
+ *
+ * @module components/ui
+ * @since 1.0.0
+ *
+ * @example
+ * // Import multiple components
+ * import { Button, Input, Card, Spinner } from '@/components/ui';
+ *
+ * @example
+ * // Import with types
+ * import {
+ * Button,
+ * type ButtonProps,
+ * Input,
+ * type InputProps,
+ * } from '@/components/ui';
+ */
+
+// -----------------------------------------------------------------------------
+// Button
+// -----------------------------------------------------------------------------
+export {
+ Button,
+ buttonVariants,
+ type ButtonProps,
+ type VariantProps,
+} from './button';
+
+// -----------------------------------------------------------------------------
+// Input
+// -----------------------------------------------------------------------------
+export {
+ Input,
+ InputWrapper,
+ inputVariants,
+ type InputProps,
+ type InputWrapperProps,
+ type InputVariants,
+ type InputVariant,
+ type InputSize,
+} from './input';
+
+// -----------------------------------------------------------------------------
+// Card
+// -----------------------------------------------------------------------------
+export {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ cardVariants,
+ type CardProps,
+ type CardHeaderProps,
+ type CardTitleProps,
+ type CardDescriptionProps,
+ type CardContentProps,
+ type CardFooterProps,
+} from './card';
+
+// -----------------------------------------------------------------------------
+// Spinner
+// -----------------------------------------------------------------------------
+export {
+ Spinner,
+ LoadingOverlay,
+ spinnerVariants,
+ type SpinnerProps,
+ type LoadingOverlayProps,
+} from './spinner';
+
+// -----------------------------------------------------------------------------
+// Theme Toggle
+// -----------------------------------------------------------------------------
+export {
+ ThemeToggle,
+ ThemeToggleDropdown,
+ ThemeToggleGroup,
+ type ThemeToggleProps,
+ type ThemeToggleDropdownProps,
+} from './theme-toggle';
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8d078a7e2c0bbf0f29d928b89236d8783144ef4c
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,533 @@
+/**
+ * Input Component Module
+ *
+ * A production-ready, accessible input component with multiple variants,
+ * sizes, and support for icons, labels, and error states. Built with
+ * React 19, TypeScript, Tailwind CSS v4, and class-variance-authority.
+ *
+ * @module components/ui/input
+ * @author RAG Chatbot Team
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage
+ *
+ *
+ * @example
+ * // With label and error
+ *
+ *
+ *
+ *
+ * @example
+ * // With icons
+ * }
+ * rightIcon={ }
+ * placeholder="Search..."
+ * />
+ */
+
+import { type VariantProps, cva } from 'class-variance-authority';
+import {
+ forwardRef,
+ useId,
+ type InputHTMLAttributes,
+ type ReactNode,
+} from 'react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Input variant styles using class-variance-authority.
+ *
+ * Defines the visual appearance variants for the Input component:
+ *
+ * **Variants:**
+ * - `default`: Standard input with transparent background, subtle border.
+ * Best for most form fields.
+ * - `filled`: Input with secondary background color for visual hierarchy.
+ * Use when you want inputs to stand out from the page background.
+ * - `error`: Red-tinted input indicating validation errors.
+ * Automatically applied when `error` prop is provided.
+ *
+ * **Sizes:**
+ * - `sm`: Compact size (32px height) for dense UIs or inline forms.
+ * - `md`: Standard size (40px height) for most use cases.
+ * - `lg`: Large size (48px height) for prominent inputs or touch targets.
+ *
+ * All variants include:
+ * - Focus ring with brand color (`--border-focus`)
+ * - Smooth transitions (`--transition-fast`)
+ * - Proper disabled state styling
+ * - WCAG AA compliant color contrast
+ */
+const inputVariants = cva(
+ [
+ // Base styles
+ 'w-full',
+ 'border',
+ 'rounded-[var(--radius-md)]',
+ 'text-[var(--foreground)]',
+ 'placeholder:text-[var(--foreground-muted)]',
+ 'transition-all',
+ 'duration-[var(--transition-fast)]',
+ // Focus styles - accessible focus ring
+ 'focus:outline-none',
+ 'focus:ring-2',
+ 'focus:ring-[var(--border-focus)]',
+ 'focus:ring-offset-1',
+ 'focus:ring-offset-[var(--background)]',
+ 'focus:border-[var(--border-focus)]',
+ // Disabled styles
+ 'disabled:cursor-not-allowed',
+ 'disabled:opacity-50',
+ 'disabled:bg-[var(--background-tertiary)]',
+ // File input styles
+ 'file:border-0',
+ 'file:bg-transparent',
+ 'file:text-sm',
+ 'file:font-medium',
+ 'file:text-[var(--foreground)]',
+ ],
+ {
+ variants: {
+ /**
+ * Visual style variant of the input.
+ *
+ * @default 'default'
+ */
+ variant: {
+ /**
+ * Default variant: Transparent background with subtle border.
+ * Suitable for most form fields and general use.
+ */
+ default: [
+ 'bg-[var(--background)]',
+ 'border-[var(--border)]',
+ 'hover:border-[var(--foreground-muted)]',
+ ],
+ /**
+ * Filled variant: Secondary background for visual prominence.
+ * Use when inputs need to stand out from the page background.
+ */
+ filled: [
+ 'bg-[var(--background-secondary)]',
+ 'border-[var(--background-secondary)]',
+ 'hover:border-[var(--border)]',
+ ],
+ /**
+ * Error variant: Red-tinted styling for validation errors.
+ * Provides immediate visual feedback for invalid input.
+ */
+ error: [
+ 'bg-[var(--background)]',
+ 'border-[var(--error)]',
+ 'hover:border-[var(--error)]',
+ 'focus:ring-[var(--error)]',
+ 'focus:border-[var(--error)]',
+ ],
+ },
+ /**
+ * Size variant controlling height and padding.
+ *
+ * @default 'md'
+ */
+ size: {
+ /**
+ * Small size: 32px height, compact padding.
+ * Ideal for dense interfaces or inline usage.
+ */
+ sm: 'h-8 px-3 text-sm',
+ /**
+ * Medium size: 40px height, standard padding.
+ * Recommended for most form fields.
+ */
+ md: 'h-10 px-4 text-base',
+ /**
+ * Large size: 48px height, generous padding.
+ * Best for prominent inputs or improved touch targets.
+ */
+ lg: 'h-12 px-5 text-lg',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'md',
+ },
+ }
+);
+
+/**
+ * Props for the Input component.
+ *
+ * Extends standard HTML input attributes with custom styling options
+ * and icon slot support.
+ */
+export interface InputProps
+ extends
+ Omit, 'size'>,
+ VariantProps {
+ /**
+ * Icon or element to display on the left side of the input.
+ * Automatically adjusts input padding to accommodate the icon.
+ *
+ * @example
+ * } />
+ */
+ leftIcon?: ReactNode;
+
+ /**
+ * Icon or element to display on the right side of the input.
+ * Useful for action buttons (clear, toggle visibility) or status indicators.
+ *
+ * @example
+ * } />
+ */
+ rightIcon?: ReactNode;
+
+ /**
+ * Additional CSS classes to apply to the input container.
+ * Use for layout adjustments when icons are present.
+ */
+ containerClassName?: string;
+}
+
+/**
+ * A versatile input component with variants, sizes, and icon support.
+ *
+ * Features:
+ * - Three visual variants: default, filled, error
+ * - Three size options: sm, md, lg
+ * - Left and right icon slots
+ * - Full accessibility support with proper ARIA attributes
+ * - Smooth focus and hover transitions
+ * - Support for all native input types
+ *
+ * The component properly forwards refs for form library integration
+ * and supports both controlled and uncontrolled usage patterns.
+ *
+ * @param props - Input component props including variant, size, and icons
+ * @returns A styled input element with optional icon containers
+ *
+ * @example
+ * // Uncontrolled usage
+ *
+ *
+ * @example
+ * // Controlled usage with icons
+ * const [value, setValue] = useState('');
+ * setValue(e.target.value)}
+ * leftIcon={ }
+ * rightIcon={value && setValue('')} />}
+ * />
+ */
+export const Input = forwardRef(
+ (
+ {
+ className,
+ containerClassName,
+ variant,
+ size,
+ leftIcon,
+ rightIcon,
+ type = 'text',
+ disabled,
+ 'aria-invalid': ariaInvalid,
+ ...props
+ },
+ ref
+ ) => {
+ // Determine if input is in error state for accessibility
+ const isError =
+ variant === 'error' || ariaInvalid === true || ariaInvalid === 'true';
+
+ // Base input element
+ const inputElement = (
+
+ );
+
+ // If no icons, return simple input
+ if (!leftIcon && !rightIcon) {
+ return inputElement;
+ }
+
+ // Icon size classes mapping based on input size
+ const iconSizeMap = {
+ sm: '[&>svg]:w-4 [&>svg]:h-4',
+ md: '[&>svg]:w-5 [&>svg]:h-5',
+ lg: '[&>svg]:w-6 [&>svg]:h-6',
+ };
+
+ const iconSizeClass = iconSizeMap[size ?? 'md'];
+
+ // With icons, wrap in a relative container
+ return (
+
+ {/* Left icon container */}
+ {leftIcon && (
+
+ {leftIcon}
+
+ )}
+
+ {inputElement}
+
+ {/* Right icon container */}
+ {rightIcon && (
+
+ {rightIcon}
+
+ )}
+
+ );
+ }
+);
+
+Input.displayName = 'Input';
+
+/**
+ * Props for the InputWrapper component.
+ *
+ * Provides a complete form field structure with label, helper text,
+ * and error message support.
+ */
+export interface InputWrapperProps {
+ /**
+ * The Input component or other form field to wrap.
+ */
+ children: ReactNode;
+
+ /**
+ * Label text displayed above the input.
+ * Associates with the input via htmlFor for accessibility.
+ */
+ label?: string;
+
+ /**
+ * Helper text displayed below the input in normal state.
+ * Provides additional context or instructions.
+ */
+ helperText?: string;
+
+ /**
+ * Error message displayed below the input in error state.
+ * Takes precedence over helperText when present.
+ * Automatically associates with input via aria-describedby.
+ */
+ error?: string;
+
+ /**
+ * Indicates if the field is required.
+ * Displays a visual indicator next to the label.
+ *
+ * @default false
+ */
+ required?: boolean;
+
+ /**
+ * Custom ID for the input element.
+ * Auto-generated if not provided.
+ */
+ id?: string;
+
+ /**
+ * Additional CSS classes for the wrapper container.
+ */
+ className?: string;
+
+ /**
+ * Disabled state that visually affects label and helper text.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+}
+
+/**
+ * A wrapper component for inputs providing label, helper text, and error display.
+ *
+ * Features:
+ * - Accessible label association via htmlFor
+ * - Required field indicator
+ * - Helper text for instructions
+ * - Error message display with proper ARIA attributes
+ * - Consistent spacing and typography
+ *
+ * Use this component to create fully accessible form fields with
+ * proper label associations and error handling.
+ *
+ * @param props - InputWrapper props including label, error, and helperText
+ * @returns A complete form field structure with label and messaging
+ *
+ * @example
+ * // Basic usage with label
+ *
+ *
+ *
+ *
+ * @example
+ * // With error state
+ *
+ *
+ *
+ *
+ * @example
+ * // With helper text
+ *
+ *
+ *
+ */
+export function InputWrapper({
+ children,
+ label,
+ helperText,
+ error,
+ required = false,
+ id,
+ className,
+ disabled = false,
+}: InputWrapperProps): ReactNode {
+ // Generate stable ID for accessibility associations
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const errorId = `${inputId}-error`;
+ const helperId = `${inputId}-helper`;
+
+ // Determine which description ID to use (reserved for future child cloning)
+ const _describedBy = error ? errorId : helperText ? helperId : undefined;
+ void _describedBy; // Intentionally unused - will be used for aria-describedby injection
+
+ return (
+
+ {/* Label */}
+ {label && (
+
+ {label}
+ {required && (
+
+ *
+
+ )}
+
+ )}
+
+ {/* Input with accessibility props injected */}
+
+ {/* Clone children to inject accessibility props if it's a single element */}
+ {children}
+
+
+ {/* Error message - takes precedence over helper text */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Helper text - shown when no error */}
+ {!error && helperText && (
+
+ {helperText}
+
+ )}
+
+ );
+}
+
+InputWrapper.displayName = 'InputWrapper';
+
+/**
+ * Exported variant types for external use.
+ *
+ * These types can be used to type-check variant values or create
+ * custom styled input variations.
+ *
+ * @example
+ * const variant: InputVariant = 'error';
+ * const size: InputSize = 'lg';
+ */
+export type InputVariants = VariantProps;
+export type InputVariant = InputVariants['variant'];
+export type InputSize = InputVariants['size'];
+
+/**
+ * Export the variant function for advanced customization.
+ *
+ * Allows consumers to compose the base input styles with
+ * additional custom variants.
+ */
+export { inputVariants };
diff --git a/frontend/src/components/ui/spinner.tsx b/frontend/src/components/ui/spinner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..49526a7221a929f1224272bc5b9f5568161f25ea
--- /dev/null
+++ b/frontend/src/components/ui/spinner.tsx
@@ -0,0 +1,458 @@
+/**
+ * Spinner/Loading Component
+ *
+ * A production-ready, accessible loading spinner component with multiple styles,
+ * sizes, and color variants. Built with class-variance-authority for type-safe
+ * variant management and designed to meet WCAG AA accessibility standards.
+ *
+ * @module components/ui/spinner
+ * @since 1.0.0
+ *
+ * @example
+ * // Default ring spinner
+ *
+ *
+ * @example
+ * // Large primary spinner with dots style
+ *
+ *
+ * @example
+ * // Custom aria label for specific context
+ *
+ *
+ * @example
+ * // Full page loading overlay
+ *
+ * Loading your data...
+ *
+ */
+
+import { type VariantProps, cva } from 'class-variance-authority';
+import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Spinner variant styles using class-variance-authority.
+ *
+ * Defines the visual appearance variations for the Spinner component.
+ * All variants support both light and dark mode through CSS variables.
+ *
+ * @remarks
+ * - Uses CSS custom properties from globals.css for consistent theming
+ * - Supports dark mode through CSS variable overrides
+ * - Includes screen reader support via aria-label and role="status"
+ */
+const spinnerVariants = cva(
+ /* Base styles applied to all spinners */
+ ['inline-flex', 'shrink-0'],
+ {
+ variants: {
+ /**
+ * Size variants controlling the spinner dimensions.
+ *
+ * @property xs - Extra small (12px). For inline text or compact indicators.
+ * @property sm - Small (16px). For buttons or small UI elements.
+ * @property md - Medium (24px). Default size for most use cases.
+ * @property lg - Large (32px). For prominent loading states.
+ * @property xl - Extra large (48px). For full-page or section loading.
+ */
+ size: {
+ xs: 'h-3 w-3',
+ sm: 'h-4 w-4',
+ md: 'h-6 w-6',
+ lg: 'h-8 w-8',
+ xl: 'h-12 w-12',
+ },
+ /**
+ * Color variants for the spinner.
+ *
+ * @property primary - Brand purple color. Use for primary loading states.
+ * @property muted - Muted foreground color. Use for subtle loading indicators.
+ * @property current - Inherits currentColor. Use when spinner should match text.
+ * @property white - White color. Use on dark backgrounds or primary buttons.
+ */
+ color: {
+ primary: 'text-[var(--color-primary-500)]',
+ muted: 'text-[var(--foreground-muted)]',
+ current: 'text-current',
+ white: 'text-white',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ color: 'primary',
+ },
+ }
+);
+
+/**
+ * Props for the Spinner component.
+ *
+ * Extends standard HTML div attributes with additional
+ * variant, style, and accessibility options.
+ */
+export interface SpinnerProps
+ extends
+ Omit, 'style' | 'color'>,
+ VariantProps {
+ /**
+ * The visual style of the spinner animation.
+ *
+ * @property ring - Classic circular spinner with rotating arc (default).
+ * @property dots - Three bouncing dots animation.
+ * @property pulse - Pulsing circle animation.
+ *
+ * @default 'ring'
+ */
+ spinnerStyle?: 'ring' | 'dots' | 'pulse';
+
+ /**
+ * Accessible label for screen readers.
+ * Describes what is being loaded for better accessibility.
+ *
+ * @default 'Loading...'
+ */
+ label?: string;
+}
+
+/**
+ * Ring spinner SVG component.
+ *
+ * Renders a circular spinner with a rotating arc animation.
+ * Uses Tailwind's built-in animate-spin for smooth rotation.
+ *
+ * @param className - Additional CSS classes to apply
+ * @returns SVG spinner element with spin animation
+ *
+ * @internal
+ */
+function RingSpinner({
+ className,
+}: {
+ className?: string;
+}): React.ReactElement {
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * Dots spinner component.
+ *
+ * Renders three dots with a bouncing animation.
+ * Each dot animates with a staggered delay for a wave effect.
+ *
+ * @param className - Additional CSS classes to apply
+ * @returns Div element containing animated dots
+ *
+ * @internal
+ */
+function DotsSpinner({
+ className,
+}: {
+ className?: string;
+}): React.ReactElement {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Pulse spinner component.
+ *
+ * Renders a pulsing circle that fades in and out.
+ * Creates a subtle, less distracting loading indicator.
+ *
+ * @param className - Additional CSS classes to apply
+ * @returns Div element with pulse animation
+ *
+ * @internal
+ */
+function PulseSpinner({
+ className,
+}: {
+ className?: string;
+}): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Spinner component with multiple styles, sizes, and colors.
+ *
+ * A versatile loading indicator that supports various visual styles
+ * and is fully accessible with proper ARIA attributes.
+ *
+ * @remarks
+ * ## Accessibility Features
+ * - role="status" for live region announcement
+ * - aria-label for screen reader description
+ * - Hidden decorative text for screen readers
+ * - aria-hidden on visual elements to prevent duplicate announcements
+ *
+ * ## Animation Performance
+ * - Uses CSS transforms for GPU-accelerated animations
+ * - Will-change hints applied for smoother animations
+ * - Respects prefers-reduced-motion media query
+ *
+ * @param props - Spinner component props
+ * @returns React div element containing the spinner
+ *
+ * @see {@link SpinnerProps} for full prop documentation
+ * @see {@link spinnerVariants} for available style variants
+ */
+const Spinner = forwardRef(
+ (
+ {
+ className,
+ size,
+ color,
+ spinnerStyle = 'ring',
+ label = 'Loading...',
+ ...props
+ },
+ ref
+ ) => {
+ /* Render the appropriate spinner style */
+ const renderSpinner = (): React.ReactElement => {
+ const spinnerClassName = cn(spinnerVariants({ size, color }), className);
+
+ switch (spinnerStyle) {
+ case 'dots':
+ return ;
+ case 'pulse':
+ return ;
+ case 'ring':
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+ {renderSpinner()}
+ {/* Screen reader only text */}
+ {label}
+
+ );
+ }
+);
+
+/* Display name for React DevTools */
+Spinner.displayName = 'Spinner';
+
+/**
+ * Props for the LoadingOverlay component.
+ *
+ * Extends standard HTML div attributes with overlay-specific options.
+ */
+export interface LoadingOverlayProps extends Omit<
+ HTMLAttributes,
+ 'color'
+> {
+ /**
+ * Whether to show a semi-transparent backdrop behind the spinner.
+ * Useful for modal-like loading states that block interaction.
+ *
+ * @default false
+ */
+ backdrop?: boolean;
+
+ /**
+ * The size of the spinner in the overlay.
+ *
+ * @default 'lg'
+ */
+ size?: VariantProps['size'];
+
+ /**
+ * The color variant of the spinner.
+ *
+ * @default 'primary'
+ */
+ color?: VariantProps['color'];
+
+ /**
+ * The visual style of the spinner animation.
+ *
+ * @default 'ring'
+ */
+ spinnerStyle?: 'ring' | 'dots' | 'pulse';
+
+ /**
+ * Accessible label for the loading state.
+ *
+ * @default 'Loading...'
+ */
+ label?: string;
+
+ /**
+ * Optional children to render below the spinner.
+ * Useful for adding loading messages or additional context.
+ */
+ children?: ReactNode;
+
+ /**
+ * Whether to use absolute positioning (for container overlays)
+ * or fixed positioning (for full-page overlays).
+ *
+ * @default 'absolute'
+ */
+ position?: 'absolute' | 'fixed';
+}
+
+/**
+ * Loading overlay component that centers a spinner with optional backdrop.
+ *
+ * Useful for creating loading states that cover a container or the entire page.
+ * Can include additional content like loading messages below the spinner.
+ *
+ * @remarks
+ * ## Usage Patterns
+ * - Use `position="fixed"` for full-page loading overlays
+ * - Use `position="absolute"` for container-specific loading (default)
+ * - Add `backdrop` for modal-like blocking behavior
+ *
+ * ## Accessibility
+ * - Inherits accessibility features from Spinner component
+ * - Backdrop prevents interaction with covered content
+ * - Announce loading state to screen readers
+ *
+ * @example
+ * // Container loading overlay
+ *
+ * {isLoading && }
+ *
+ *
+ *
+ * @example
+ * // Full page loading overlay with message
+ * {isLoading && (
+ *
+ *
+ * Loading your dashboard...
+ *
+ *
+ * )}
+ *
+ * @param props - LoadingOverlay component props
+ * @returns React div element containing centered spinner
+ */
+const LoadingOverlay = forwardRef(
+ (
+ {
+ className,
+ backdrop = false,
+ size = 'lg',
+ color = 'primary',
+ spinnerStyle = 'ring',
+ label = 'Loading...',
+ children,
+ position = 'absolute',
+ ...props
+ },
+ ref
+ ) => {
+ return (
+
+
+ {children}
+
+ );
+ }
+);
+
+/* Display name for React DevTools */
+LoadingOverlay.displayName = 'LoadingOverlay';
+
+/* Export components and variant types */
+export { Spinner, LoadingOverlay, spinnerVariants };
+export type { VariantProps };
diff --git a/frontend/src/components/ui/theme-toggle.tsx b/frontend/src/components/ui/theme-toggle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a744a6f04cfd618bed1369bfe2b1b7c7f2f93ade
--- /dev/null
+++ b/frontend/src/components/ui/theme-toggle.tsx
@@ -0,0 +1,436 @@
+'use client';
+
+/**
+ * Theme Toggle Component
+ *
+ * A three-state toggle button for switching between light, dark, and system
+ * theme modes. Uses lucide-react icons and the existing Button component
+ * for consistent styling.
+ *
+ * @module components/ui/theme-toggle
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage in a navbar
+ * import { ThemeToggle } from '@/components/ui/theme-toggle';
+ *
+ * function Navbar() {
+ * return (
+ *
+ *
+ *
+ *
+ * );
+ * }
+ *
+ * @example
+ * // With custom size
+ *
+ *
+ * @example
+ * // With custom variant
+ *
+ */
+
+import { useCallback, useMemo, type ReactElement } from 'react';
+import { Moon, Sun, Monitor } from 'lucide-react';
+import { Button, type ButtonProps } from '@/components/ui/button';
+import { useTheme, type Theme } from '@/components/providers/theme-provider';
+import { cn } from '@/lib/utils';
+
+/**
+ * Configuration for each theme option.
+ *
+ * @internal
+ */
+interface ThemeOption {
+ /** Theme value */
+ value: Theme;
+ /** Display label for accessibility */
+ label: string;
+ /** Icon component to render */
+ icon: typeof Sun;
+}
+
+/**
+ * Theme options configuration.
+ *
+ * Defines the available themes with their icons and labels.
+ * Order determines the cycling sequence when clicking the toggle.
+ *
+ * @internal
+ */
+const THEME_OPTIONS: readonly ThemeOption[] = [
+ { value: 'light', label: 'Light mode', icon: Sun },
+ { value: 'dark', label: 'Dark mode', icon: Moon },
+ { value: 'system', label: 'System preference', icon: Monitor },
+] as const;
+
+/**
+ * Props for the ThemeToggle component.
+ *
+ * Extends Button props (excluding children) for full customization.
+ */
+export interface ThemeToggleProps extends Omit<
+ ButtonProps,
+ 'children' | 'onClick' | 'aria-label'
+> {
+ /**
+ * Whether to show the theme label next to the icon.
+ * Useful for settings pages or when more context is needed.
+ *
+ * @default false
+ */
+ showLabel?: boolean;
+
+ /**
+ * Custom class name for the icon element.
+ * Useful for adjusting icon size or adding animations.
+ */
+ iconClassName?: string;
+}
+
+/**
+ * Gets the next theme in the cycle.
+ *
+ * @param currentTheme - Current theme value
+ * @returns Next theme in the sequence: light -> dark -> system -> light
+ *
+ * @internal
+ */
+function getNextTheme(currentTheme: Theme): Theme {
+ const currentIndex = THEME_OPTIONS.findIndex(
+ (option) => option.value === currentTheme
+ );
+ const nextIndex = (currentIndex + 1) % THEME_OPTIONS.length;
+ return THEME_OPTIONS[nextIndex].value;
+}
+
+/**
+ * Gets the theme option configuration for a given theme.
+ *
+ * @param theme - Theme value to look up
+ * @returns Theme option configuration
+ *
+ * @internal
+ */
+function getThemeOption(theme: Theme): ThemeOption {
+ return (
+ THEME_OPTIONS.find((option) => option.value === theme) ?? THEME_OPTIONS[0]
+ );
+}
+
+/**
+ * Theme Toggle Button Component
+ *
+ * A button that cycles through theme options (light -> dark -> system).
+ * Displays the current theme's icon and optionally a label.
+ *
+ * @remarks
+ * ## Accessibility
+ * - Uses aria-label to announce current theme and action
+ * - Button is focusable and keyboard-accessible
+ * - Icon is decorative (aria-hidden)
+ *
+ * ## Loading State
+ * Shows a skeleton placeholder while the theme is being loaded
+ * from localStorage to prevent hydration mismatch.
+ *
+ * ## Styling
+ * - Uses ghost variant by default for minimal visual impact
+ * - Uses icon size by default (square button)
+ * - Can be customized using all Button props
+ *
+ * @param props - ThemeToggle props
+ * @returns Theme toggle button element
+ *
+ * @see {@link ThemeToggleProps} for available props
+ */
+export function ThemeToggle({
+ showLabel = false,
+ iconClassName,
+ variant = 'ghost',
+ size = 'icon',
+ className,
+ ...props
+}: ThemeToggleProps): ReactElement {
+ const { theme, setTheme, isLoaded } = useTheme();
+
+ /**
+ * Current theme option configuration.
+ */
+ const currentOption = useMemo(() => getThemeOption(theme), [theme]);
+
+ /**
+ * Handle click to cycle to next theme.
+ */
+ const handleClick = useCallback(() => {
+ const nextTheme = getNextTheme(theme);
+ setTheme(nextTheme);
+ }, [theme, setTheme]);
+
+ /**
+ * Compute icon size class based on button size.
+ */
+ const computedIconClassName = useMemo(() => {
+ const sizeClass =
+ size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4';
+ return cn(sizeClass, iconClassName);
+ }, [size, iconClassName]);
+
+ /**
+ * Aria label for accessibility.
+ * Announces current theme and what clicking will do.
+ */
+ const ariaLabel = useMemo(() => {
+ const nextTheme = getNextTheme(theme);
+ const nextOption = getThemeOption(nextTheme);
+ return `Current theme: ${currentOption.label}. Click to switch to ${nextOption.label.toLowerCase()}`;
+ }, [theme, currentOption]);
+
+ // Show skeleton while loading to prevent hydration mismatch
+ if (!isLoaded) {
+ return (
+
+
+ {showLabel && (
+
+ )}
+
+ );
+ }
+
+ const IconComponent = currentOption.icon;
+
+ return (
+
+
+ {showLabel && {currentOption.label} }
+
+ );
+}
+
+/**
+ * Theme Toggle Dropdown Component
+ *
+ * An alternative theme toggle that shows all options in a dropdown menu.
+ * Useful when you want to show all options at once instead of cycling.
+ *
+ * @remarks
+ * This is a simpler implementation using native HTML elements.
+ * For a more polished dropdown, consider using a headless UI library
+ * like Radix UI or Headless UI.
+ *
+ * @param props - ThemeToggle props (without showLabel, which is always true)
+ * @returns Theme toggle dropdown element
+ *
+ * @example
+ * // In a settings page
+ * import { ThemeToggleDropdown } from '@/components/ui/theme-toggle';
+ *
+ * function SettingsPage() {
+ * return (
+ *
+ *
Appearance
+ *
+ * Theme
+ *
+ *
+ *
+ * );
+ * }
+ */
+/**
+ * Props for the ThemeToggleDropdown component.
+ */
+export interface ThemeToggleDropdownProps extends React.HTMLAttributes {
+ /**
+ * Custom class name for the container element.
+ */
+ className?: string;
+}
+
+export function ThemeToggleDropdown({
+ className,
+ ...props
+}: ThemeToggleDropdownProps): ReactElement {
+ const { theme, setTheme, isLoaded } = useTheme();
+
+ /**
+ * Handle select change.
+ */
+ const handleChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const newTheme = event.target.value as Theme;
+ setTheme(newTheme);
+ },
+ [setTheme]
+ );
+
+ // Show skeleton while loading
+ if (!isLoaded) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {THEME_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ {/* Custom dropdown arrow */}
+
+
+ );
+}
+
+/**
+ * Theme Toggle Group Component
+ *
+ * A segmented control showing all theme options as buttons.
+ * Useful for settings pages where all options should be visible.
+ *
+ * @param props - Standard div props for the container
+ * @returns Theme toggle group element
+ *
+ * @example
+ * // In a settings page
+ * import { ThemeToggleGroup } from '@/components/ui/theme-toggle';
+ *
+ * function AppearanceSettings() {
+ * return (
+ *
+ * Theme
+ *
+ *
+ * );
+ * }
+ */
+export function ThemeToggleGroup({
+ className,
+ ...props
+}: React.HTMLAttributes): ReactElement {
+ const { theme, setTheme, isLoaded } = useTheme();
+
+ // Show skeleton while loading
+ if (!isLoaded) {
+ return (
+
+ {THEME_OPTIONS.map((option) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {THEME_OPTIONS.map((option) => {
+ const isSelected = theme === option.value;
+ const IconComponent = option.icon;
+
+ return (
+ setTheme(option.value)}
+ className={cn(
+ 'inline-flex items-center justify-center gap-1.5',
+ 'h-7 rounded-[var(--radius-sm)] px-3 text-sm font-medium',
+ 'transition-all duration-[var(--transition-fast)]',
+ 'focus-visible:ring-2 focus-visible:outline-none',
+ 'focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2',
+ 'focus-visible:ring-offset-[var(--background)]',
+ isSelected
+ ? 'bg-[var(--background)] text-[var(--foreground)] shadow-[var(--shadow-sm)]'
+ : 'text-[var(--foreground-secondary)] hover:text-[var(--foreground)]'
+ )}
+ aria-pressed={isSelected}
+ aria-label={option.label}
+ >
+
+ {option.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/config/constants.ts b/frontend/src/config/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7ed8734c881346fa7530460c1362432be448f367
--- /dev/null
+++ b/frontend/src/config/constants.ts
@@ -0,0 +1,108 @@
+/**
+ * Application Constants and Configuration
+ *
+ * This module centralizes all application constants, default values,
+ * and configuration options. Modify these values to customize
+ * the application behavior.
+ *
+ * @module config/constants
+ */
+
+/**
+ * API endpoint configuration.
+ *
+ * In production on HuggingFace Spaces, the API runs on the same origin.
+ * In development, it may run on a different port.
+ */
+export const API_CONFIG = {
+ /** Base URL for API requests (empty for same-origin) */
+ baseUrl: process.env.NEXT_PUBLIC_API_URL ?? '',
+ /** Default request timeout in milliseconds */
+ timeout: 30000,
+ /** SSE endpoint for streaming queries */
+ queryEndpoint: '/api/query',
+ /** Provider status endpoint */
+ providersEndpoint: '/api/providers',
+ /** Health check endpoint */
+ healthEndpoint: '/health',
+} as const;
+
+/**
+ * Chat interface configuration.
+ */
+export const CHAT_CONFIG = {
+ /** Maximum query length in characters */
+ maxQueryLength: 1000,
+ /** Placeholder text for the input field */
+ inputPlaceholder: 'Ask about thermal comfort standards...',
+ /** Number of messages to keep in history */
+ maxHistoryLength: 50,
+ /**
+ * Maximum conversation history messages to send to the API.
+ * This limits the context window usage while maintaining conversation continuity.
+ * Should match the backend MAX_HISTORY_MESSAGES constant.
+ */
+ maxHistoryForAPI: 10,
+ /** Debounce delay for typing indicators (ms) */
+ typingDebounce: 150,
+} as const;
+
+/**
+ * UI animation and timing configuration.
+ */
+export const UI_CONFIG = {
+ /** Duration of fade animations (ms) */
+ fadeAnimationMs: 200,
+ /** Duration of slide animations (ms) */
+ slideAnimationMs: 300,
+ /** Interval for provider status refresh (ms) */
+ statusRefreshInterval: 30000,
+ /** Auto-scroll debounce delay (ms) */
+ autoScrollDebounce: 100,
+} as const;
+
+/**
+ * LLM Provider display configuration.
+ *
+ * Models match the backend implementation:
+ * - Gemini: gemini-2.5-flash-lite (primary), gemini-2.5-flash, gemini-3-flash, gemma-3-27b-it
+ * - Groq: openai/gpt-oss-120b (primary), llama-3.3-70b-versatile
+ */
+export const PROVIDERS = {
+ gemini: {
+ id: 'gemini' as const,
+ name: 'Gemini',
+ description: 'Gemini Flash / Gemma',
+ color: 'blue',
+ },
+ groq: {
+ id: 'groq' as const,
+ name: 'Groq',
+ description: 'GPT-OSS / Llama 3.3',
+ color: 'purple',
+ },
+ deepseek: {
+ id: 'deepseek' as const,
+ name: 'DeepSeek',
+ description: 'DeepSeek Chat',
+ color: 'green',
+ },
+ anthropic: {
+ id: 'anthropic' as const,
+ name: 'Anthropic',
+ description: 'Claude',
+ color: 'orange',
+ },
+} as const;
+
+/**
+ * Application metadata.
+ */
+export const APP_METADATA = {
+ title: 'pythermalcomfort Chat',
+ description:
+ 'Ask questions about thermal comfort standards and the pythermalcomfort Python library.',
+ repository:
+ 'https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort',
+ documentation: 'https://pythermalcomfort.readthedocs.io/',
+} as const;
diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c754b867d473b709977a8a457e9c3a76eba83d59
--- /dev/null
+++ b/frontend/src/contexts/index.ts
@@ -0,0 +1,14 @@
+/**
+ * Context Exports
+ *
+ * Centralized export for all React contexts.
+ *
+ * @module contexts
+ */
+
+export {
+ ProviderProvider,
+ useProviderContext,
+ type ProviderStatus,
+ type ProviderContextValue,
+} from './provider-context';
diff --git a/frontend/src/contexts/provider-context.tsx b/frontend/src/contexts/provider-context.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a8cf065933e55f00e2756222f3d02b56e80b5dcd
--- /dev/null
+++ b/frontend/src/contexts/provider-context.tsx
@@ -0,0 +1,431 @@
+/**
+ * Provider Context
+ *
+ * Shares LLM provider state across all components in the application.
+ * This ensures that when a provider is selected in one component,
+ * all other components (like the sidebar) update immediately.
+ *
+ * @module contexts/provider-context
+ * @since 1.0.0
+ */
+
+'use client';
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import { API_CONFIG, UI_CONFIG, PROVIDERS } from '@/config/constants';
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * localStorage key for persisting selected provider.
+ * @internal
+ */
+const STORAGE_KEY = 'rag-chatbot-selected-provider';
+
+// ============================================================================
+// Type Definitions
+// ============================================================================
+
+/**
+ * Model status information from the backend API.
+ * @internal
+ */
+interface BackendModelStatus {
+ model_name: string;
+ is_available: boolean;
+ requests_remaining_minute: number;
+ tokens_remaining_minute: number;
+ requests_remaining_day: number;
+ tokens_remaining_day: number | null;
+ cooldown_seconds: number | null;
+ reason: string | null;
+}
+
+/**
+ * Provider status information from the backend API.
+ * @internal
+ */
+interface BackendProviderStatus {
+ provider: string;
+ is_available: boolean;
+ models: BackendModelStatus[];
+}
+
+/**
+ * Response from the /api/providers endpoint.
+ * @internal
+ */
+interface BackendProvidersResponse {
+ providers: BackendProviderStatus[];
+ cached: boolean;
+ cache_expires_in_seconds: number | null;
+}
+
+/**
+ * Provider status for frontend consumption.
+ */
+export interface ProviderStatus {
+ id: string;
+ name: string;
+ description: string;
+ isAvailable: boolean;
+ cooldownSeconds: number | null;
+ totalRequestsRemaining: number;
+ primaryModel: string | null;
+ allModels: string[];
+}
+
+/**
+ * Context value type.
+ */
+export interface ProviderContextValue {
+ providers: ProviderStatus[];
+ selectedProvider: string | null;
+ selectProvider: (providerId: string | null) => void;
+ isLoading: boolean;
+ error: string | null;
+ refresh: () => void;
+ lastUpdated: Date | null;
+}
+
+// ============================================================================
+// Context
+// ============================================================================
+
+const ProviderContext = createContext(null);
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function getProviderDisplayInfo(providerId: string): {
+ name: string;
+ description: string;
+} {
+ const knownProvider = PROVIDERS[providerId as keyof typeof PROVIDERS];
+ if (knownProvider) {
+ return {
+ name: knownProvider.name,
+ description: knownProvider.description,
+ };
+ }
+ const capitalizedId =
+ providerId.charAt(0).toUpperCase() + providerId.slice(1);
+ return {
+ name: capitalizedId,
+ description: `${capitalizedId} LLM Provider`,
+ };
+}
+
+function transformProviderStatus(
+ backend: BackendProviderStatus
+): ProviderStatus {
+ const displayInfo = getProviderDisplayInfo(backend.provider);
+
+ let maxCooldown: number | null = null;
+ for (const model of backend.models) {
+ if (model.cooldown_seconds !== null) {
+ maxCooldown =
+ maxCooldown === null
+ ? model.cooldown_seconds
+ : Math.max(maxCooldown, model.cooldown_seconds);
+ }
+ }
+
+ let totalRequestsRemaining = 0;
+ let hasAnyLimit = false;
+
+ for (const model of backend.models) {
+ if (model.is_available) {
+ const minuteLimit = model.requests_remaining_minute;
+ const dayLimit = model.requests_remaining_day;
+ const effectiveLimit = Math.min(minuteLimit, dayLimit);
+ totalRequestsRemaining += effectiveLimit;
+ hasAnyLimit = true;
+ }
+ }
+
+ if (!hasAnyLimit) {
+ totalRequestsRemaining = Infinity;
+ }
+
+ const allModels = backend.models.map((m) => m.model_name);
+ // Find the first AVAILABLE model - this is the one that will actually be used
+ const firstAvailableModel = backend.models.find((m) => m.is_available);
+ const primaryModel = firstAvailableModel?.model_name ?? allModels[0] ?? null;
+
+ return {
+ id: backend.provider,
+ name: displayInfo.name,
+ description: displayInfo.description,
+ isAvailable: backend.is_available,
+ cooldownSeconds: maxCooldown,
+ totalRequestsRemaining,
+ primaryModel,
+ allModels,
+ };
+}
+
+function readStoredProvider(): string | null {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored && typeof stored === 'string' && stored.trim().length > 0) {
+ return stored;
+ }
+ return null;
+ } catch {
+ console.warn('useProviders: Unable to read from localStorage');
+ return null;
+ }
+}
+
+function writeStoredProvider(providerId: string | null): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ if (providerId === null) {
+ localStorage.removeItem(STORAGE_KEY);
+ } else {
+ localStorage.setItem(STORAGE_KEY, providerId);
+ }
+ } catch {
+ console.warn('useProviders: Unable to write to localStorage');
+ }
+}
+
+function validateStoredProvider(
+ storedId: string | null,
+ providers: ProviderStatus[]
+): string | null {
+ if (storedId === null) {
+ return null;
+ }
+ const exists = providers.some((p) => p.id === storedId);
+ return exists ? storedId : null;
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
+ return 'Unable to connect to the server. Please check your internet connection.';
+ }
+ if (error.name === 'AbortError') {
+ return 'Request timed out. Please try again.';
+ }
+ return error.message;
+ }
+ return 'An unexpected error occurred while fetching provider status.';
+}
+
+// ============================================================================
+// Provider Component
+// ============================================================================
+
+interface ProviderProviderProps {
+ children: ReactNode;
+}
+
+/**
+ * ProviderProvider Component
+ *
+ * Wraps the application to provide shared provider state.
+ * All components using useProviders() will share the same state.
+ */
+export function ProviderProvider({
+ children,
+}: ProviderProviderProps): React.ReactElement {
+ const [providers, setProviders] = useState([]);
+ const [selectedProvider, setSelectedProvider] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [lastUpdated, setLastUpdated] = useState(null);
+
+ const hasInitialFetchRef = useRef(false);
+ const intervalRef = useRef | null>(null);
+ const abortControllerRef = useRef(null);
+ const isMountedRef = useRef(true);
+
+ const fetchProviders = useCallback(async () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, API_CONFIG.timeout);
+
+ try {
+ const url = `${API_CONFIG.baseUrl}${API_CONFIG.providersEndpoint}`;
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ },
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ let errorMessage = `Server error: ${response.status}`;
+ try {
+ const errorBody = await response.json();
+ if (errorBody.message) {
+ errorMessage = errorBody.message;
+ } else if (errorBody.detail) {
+ errorMessage = errorBody.detail;
+ }
+ } catch {
+ // Ignore
+ }
+ throw new Error(errorMessage);
+ }
+
+ const data: BackendProvidersResponse = await response.json();
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ const transformedProviders = data.providers
+ .map(transformProviderStatus)
+ .sort((a, b) => a.id.localeCompare(b.id));
+
+ setProviders(transformedProviders);
+ setError(null);
+ setLastUpdated(new Date());
+
+ if (!hasInitialFetchRef.current) {
+ hasInitialFetchRef.current = true;
+ setIsLoading(false);
+
+ const storedProvider = readStoredProvider();
+ const validatedProvider = validateStoredProvider(
+ storedProvider,
+ transformedProviders
+ );
+
+ setSelectedProvider(validatedProvider);
+
+ if (storedProvider !== null && validatedProvider === null) {
+ writeStoredProvider(null);
+ console.info(
+ `ProviderContext: Cleared invalid stored provider "${storedProvider}"`
+ );
+ }
+ }
+ } catch (fetchError) {
+ clearTimeout(timeoutId);
+
+ if (fetchError instanceof Error && fetchError.name === 'AbortError') {
+ return;
+ }
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ const errorMessage = getErrorMessage(fetchError);
+ setError(errorMessage);
+
+ if (!hasInitialFetchRef.current) {
+ hasInitialFetchRef.current = true;
+ setIsLoading(false);
+ }
+
+ console.warn('ProviderContext: Fetch failed:', errorMessage);
+ } finally {
+ if (abortControllerRef.current === controller) {
+ abortControllerRef.current = null;
+ }
+ }
+ }, []);
+
+ const selectProvider = useCallback((providerId: string | null) => {
+ setSelectedProvider(providerId);
+ writeStoredProvider(providerId);
+ }, []);
+
+ const refresh = useCallback(() => {
+ fetchProviders();
+ }, [fetchProviders]);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ fetchProviders();
+
+ intervalRef.current = setInterval(() => {
+ fetchProviders();
+ }, UI_CONFIG.statusRefreshInterval);
+
+ return () => {
+ isMountedRef.current = false;
+
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ };
+ }, [fetchProviders]);
+
+ const value: ProviderContextValue = {
+ providers,
+ selectedProvider,
+ selectProvider,
+ isLoading,
+ error,
+ refresh,
+ lastUpdated,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+/**
+ * useProviderContext Hook
+ *
+ * Access the shared provider state from context.
+ * Must be used within a ProviderProvider.
+ *
+ * @throws Error if used outside ProviderProvider
+ */
+export function useProviderContext(): ProviderContextValue {
+ const context = useContext(ProviderContext);
+ if (context === null) {
+ throw new Error(
+ 'useProviderContext must be used within a ProviderProvider'
+ );
+ }
+ return context;
+}
diff --git a/frontend/src/hooks/__tests__/use-providers.test.tsx b/frontend/src/hooks/__tests__/use-providers.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b1e6597a2b6155ddd59be9a7fe736edf20a8d31f
--- /dev/null
+++ b/frontend/src/hooks/__tests__/use-providers.test.tsx
@@ -0,0 +1,1745 @@
+/**
+ * Unit Tests for useProviders Hook
+ *
+ * Comprehensive test coverage for the provider management hook.
+ * Tests initial state, fetching, provider selection, polling,
+ * error handling, localStorage persistence, and cleanup.
+ *
+ * @module hooks/__tests__/use-providers.test
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { type ReactNode } from 'react';
+import { useProviders } from '../use-providers';
+import type { ProviderStatus } from '../use-providers';
+import { ProviderProvider } from '@/contexts/provider-context';
+
+/**
+ * Wrapper component for renderHook that provides the ProviderContext.
+ */
+function createWrapper() {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+ };
+}
+
+// ============================================================================
+// Test Helpers
+// ============================================================================
+
+/**
+ * Mock response from the /api/providers endpoint.
+ */
+interface MockProvidersResponse {
+ providers: Array<{
+ provider: string;
+ is_available: boolean;
+ models: Array<{
+ model_name: string;
+ is_available: boolean;
+ requests_remaining_minute: number;
+ tokens_remaining_minute: number;
+ requests_remaining_day: number;
+ tokens_remaining_day: number | null;
+ cooldown_seconds: number | null;
+ reason: string | null;
+ }>;
+ }>;
+ cached: boolean;
+ cache_expires_in_seconds: number | null;
+}
+
+/**
+ * Create a standard mock providers response.
+ */
+function createMockProvidersResponse(
+ overrides: Partial = {}
+): MockProvidersResponse {
+ return {
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'gemini-2.5-flash-lite',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 250000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ ],
+ cached: false,
+ cache_expires_in_seconds: null,
+ ...overrides,
+ };
+}
+
+/**
+ * Create a mock Response object.
+ */
+function createMockResponse(
+ data: MockProvidersResponse,
+ options: { ok?: boolean; status?: number } = {}
+): Response {
+ const { ok = true, status = 200 } = options;
+ return {
+ ok,
+ status,
+ json: vi.fn().mockResolvedValue(data),
+ } as unknown as Response;
+}
+
+/**
+ * Create a mock error Response object.
+ */
+function createMockErrorResponse(
+ status: number,
+ errorBody?: { message?: string; detail?: string }
+): Response {
+ return {
+ ok: false,
+ status,
+ json: vi.fn().mockResolvedValue(errorBody ?? {}),
+ } as unknown as Response;
+}
+
+/**
+ * Mock localStorage implementation.
+ */
+function createMockLocalStorage(): {
+ getItem: ReturnType;
+ setItem: ReturnType;
+ removeItem: ReturnType;
+ clear: ReturnType;
+} {
+ let store: Record = {};
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key];
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ };
+}
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('useProviders', () => {
+ let mockLocalStorage: ReturnType;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ // Setup localStorage mock
+ mockLocalStorage = createMockLocalStorage();
+ Object.defineProperty(global, 'localStorage', {
+ value: mockLocalStorage,
+ writable: true,
+ });
+
+ // Setup default fetch mock
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllTimers();
+ });
+
+ // ==========================================================================
+ // Initial State and Loading Tests
+ // ==========================================================================
+
+ describe('initial state and loading', () => {
+ it('should start in loading state', () => {
+ // Mock fetch to never resolve so we stay in loading state
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('should have empty providers array initially', () => {
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ expect(result.current.providers).toEqual([]);
+ });
+
+ it('should have null selectedProvider initially', () => {
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ expect(result.current.selectedProvider).toBeNull();
+ });
+
+ it('should have null error initially', () => {
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should have null lastUpdated initially', () => {
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ expect(result.current.lastUpdated).toBeNull();
+ });
+ });
+
+ // ==========================================================================
+ // Successful Fetch Tests
+ // ==========================================================================
+
+ describe('successful fetch', () => {
+ it('should fetch providers on mount', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse();
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('/api/providers'),
+ expect.objectContaining({
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ })
+ );
+ });
+
+ it('should update providers state after fetch', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'gemini-2.5-flash-lite',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 250000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ {
+ provider: 'groq',
+ is_available: false,
+ models: [
+ {
+ model_name: 'llama-3.1-8b',
+ is_available: false,
+ requests_remaining_minute: 0,
+ tokens_remaining_minute: 0,
+ requests_remaining_day: 0,
+ tokens_remaining_day: 0,
+ cooldown_seconds: 60,
+ reason: 'Rate limited',
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(2);
+ });
+
+ expect(result.current.providers[0].id).toBe('gemini');
+ expect(result.current.providers[1].id).toBe('groq');
+ });
+
+ it('should set isLoading to false after fetch', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it('should set lastUpdated timestamp after fetch', async () => {
+ vi.useRealTimers();
+
+ const beforeFetch = new Date();
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.lastUpdated).not.toBeNull();
+ });
+
+ const afterFetch = new Date();
+ expect(result.current.lastUpdated!.getTime()).toBeGreaterThanOrEqual(
+ beforeFetch.getTime()
+ );
+ expect(result.current.lastUpdated!.getTime()).toBeLessThanOrEqual(
+ afterFetch.getTime()
+ );
+ });
+
+ it('should transform backend provider format to frontend format', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'gemini-2.5-flash-lite',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 250000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ const provider = result.current.providers[0];
+ expect(provider.id).toBe('gemini');
+ expect(provider.name).toBe('Gemini');
+ expect(provider.description).toBe('Gemini Flash / Gemma');
+ expect(provider.isAvailable).toBe(true);
+ expect(provider.cooldownSeconds).toBeNull();
+ // Effective limit is min(minute, day) = min(10, 20) = 10
+ expect(provider.totalRequestsRemaining).toBe(10);
+ // Model names from backend (matches mock data)
+ expect(provider.primaryModel).toBe('gemini-2.5-flash-lite');
+ expect(provider.allModels).toEqual(['gemini-2.5-flash-lite']);
+ });
+
+ it('should sort providers by ID', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'groq',
+ is_available: true,
+ models: [
+ {
+ model_name: 'llama-3.1-8b',
+ is_available: true,
+ requests_remaining_minute: 5,
+ tokens_remaining_minute: 100000,
+ requests_remaining_day: 100,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ {
+ provider: 'anthropic',
+ is_available: true,
+ models: [
+ {
+ model_name: 'claude-3',
+ is_available: true,
+ requests_remaining_minute: 3,
+ tokens_remaining_minute: 50000,
+ requests_remaining_day: 50,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'gemini-pro',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 250000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(3);
+ });
+
+ // Should be sorted alphabetically by ID
+ expect(result.current.providers[0].id).toBe('anthropic');
+ expect(result.current.providers[1].id).toBe('gemini');
+ expect(result.current.providers[2].id).toBe('groq');
+ });
+
+ it('should handle unknown provider with fallback display info', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'newprovider',
+ is_available: true,
+ models: [
+ {
+ model_name: 'new-model',
+ is_available: true,
+ requests_remaining_minute: 5,
+ tokens_remaining_minute: 100000,
+ requests_remaining_day: 50,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ const provider = result.current.providers[0];
+ expect(provider.id).toBe('newprovider');
+ expect(provider.name).toBe('Newprovider'); // Capitalized
+ expect(provider.description).toBe('Newprovider LLM Provider');
+ });
+
+ it('should calculate aggregate cooldown from models', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: false,
+ models: [
+ {
+ model_name: 'model-1',
+ is_available: false,
+ requests_remaining_minute: 0,
+ tokens_remaining_minute: 0,
+ requests_remaining_day: 0,
+ tokens_remaining_day: 0,
+ cooldown_seconds: 30,
+ reason: 'Rate limited',
+ },
+ {
+ model_name: 'model-2',
+ is_available: false,
+ requests_remaining_minute: 0,
+ tokens_remaining_minute: 0,
+ requests_remaining_day: 0,
+ tokens_remaining_day: 0,
+ cooldown_seconds: 60,
+ reason: 'Rate limited',
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ // Should use maximum cooldown
+ expect(result.current.providers[0].cooldownSeconds).toBe(60);
+ });
+
+ it('should sum requests remaining across available models', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'model-1',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 100000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ {
+ model_name: 'model-2',
+ is_available: true,
+ requests_remaining_minute: 5,
+ tokens_remaining_minute: 50000,
+ requests_remaining_day: 15,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ {
+ model_name: 'model-3',
+ is_available: false,
+ requests_remaining_minute: 0,
+ tokens_remaining_minute: 0,
+ requests_remaining_day: 0,
+ tokens_remaining_day: 0,
+ cooldown_seconds: 30,
+ reason: 'Unavailable',
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ // model-1: min(10, 20) = 10
+ // model-2: min(5, 15) = 5
+ // model-3: not available, excluded
+ // Total: 10 + 5 = 15
+ expect(result.current.providers[0].totalRequestsRemaining).toBe(15);
+ });
+
+ it('should return Infinity when no models are available', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: false,
+ models: [
+ {
+ model_name: 'model-1',
+ is_available: false,
+ requests_remaining_minute: 0,
+ tokens_remaining_minute: 0,
+ requests_remaining_day: 0,
+ tokens_remaining_day: 0,
+ cooldown_seconds: 30,
+ reason: 'Unavailable',
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ expect(result.current.providers[0].totalRequestsRemaining).toBe(Infinity);
+ });
+ });
+
+ // ==========================================================================
+ // Provider Selection Tests
+ // ==========================================================================
+
+ describe('provider selection', () => {
+ it('should update selectedProvider when selectProvider is called', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse();
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ act(() => {
+ result.current.selectProvider('gemini');
+ });
+
+ expect(result.current.selectedProvider).toBe('gemini');
+ });
+
+ it('should persist selection to localStorage', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ act(() => {
+ result.current.selectProvider('gemini');
+ });
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ 'rag-chatbot-selected-provider',
+ 'gemini'
+ );
+ });
+
+ it('should load selection from localStorage on mount', async () => {
+ vi.useRealTimers();
+
+ // Pre-populate localStorage with a selection
+ mockLocalStorage.getItem.mockReturnValue('gemini');
+
+ const mockResponse = createMockProvidersResponse();
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await waitFor(() => {
+ expect(result.current.selectedProvider).toBe('gemini');
+ });
+ });
+
+ it('should validate stored provider against available providers', async () => {
+ vi.useRealTimers();
+
+ // Stored provider exists in the response
+ mockLocalStorage.getItem.mockReturnValue('gemini');
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'gemini-pro',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 250000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.selectedProvider).toBe('gemini');
+ });
+
+ // localStorage should not be cleared since provider is valid
+ expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
+ });
+
+ it('should clear invalid stored provider', async () => {
+ vi.useRealTimers();
+
+ // Stored provider does NOT exist in the response
+ mockLocalStorage.getItem.mockReturnValue('invalid-provider');
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'gemini-pro',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 250000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Should clear the invalid stored provider
+ expect(result.current.selectedProvider).toBeNull();
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
+ 'rag-chatbot-selected-provider'
+ );
+ });
+
+ it('should remove storage key when null is selected', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // First select a provider
+ act(() => {
+ result.current.selectProvider('gemini');
+ });
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalled();
+
+ // Then deselect (set to null for auto mode)
+ act(() => {
+ result.current.selectProvider(null);
+ });
+
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
+ 'rag-chatbot-selected-provider'
+ );
+ expect(result.current.selectedProvider).toBeNull();
+ });
+ });
+
+ // ==========================================================================
+ // Periodic Polling Tests
+ // ==========================================================================
+
+ describe('periodic polling', () => {
+ it('should poll for provider status at configured interval (30s)', async () => {
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Initial fetch
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Advance time by 30 seconds (polling interval)
+ await act(async () => {
+ vi.advanceTimersByTime(30000);
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+
+ // Advance time by another 30 seconds
+ await act(async () => {
+ vi.advanceTimersByTime(30000);
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(3);
+ });
+
+ it('should continue polling even after errors', async () => {
+ // First call succeeds
+ vi.mocked(global.fetch)
+ .mockResolvedValueOnce(createMockResponse(createMockProvidersResponse()))
+ // Second call fails
+ .mockRejectedValueOnce(new Error('Network error'))
+ // Third call succeeds
+ .mockResolvedValueOnce(createMockResponse(createMockProvidersResponse()));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Initial fetch
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Advance to trigger second poll (should fail)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30000);
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(result.current.error).not.toBeNull();
+
+ // Advance to trigger third poll (should succeed)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30000);
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(3);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should clear interval on unmount', async () => {
+ const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { unmount } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Unmount the hook
+ unmount();
+
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+ });
+
+ // ==========================================================================
+ // Error Handling Tests
+ // ==========================================================================
+
+ describe('error handling', () => {
+ it('should set error state on fetch failure', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe('Network error');
+ });
+
+ it('should set isLoading to false on error', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it('should clear error on successful fetch', async () => {
+ vi.useRealTimers();
+
+ // First call fails, second succeeds
+ vi.mocked(global.fetch)
+ .mockRejectedValueOnce(new Error('Network error'))
+ .mockResolvedValueOnce(createMockResponse(createMockProvidersResponse()));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Wait for error
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ // Trigger refresh
+ await act(async () => {
+ result.current.refresh();
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ it('should handle network errors gracefully', async () => {
+ vi.useRealTimers();
+
+ const networkError = new TypeError('Failed to fetch');
+ vi.mocked(global.fetch).mockRejectedValue(networkError);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toContain('connect');
+ });
+
+ it('should handle server errors (non-200 status)', async () => {
+ vi.useRealTimers();
+
+ const mockErrorResponse = createMockErrorResponse(500, {
+ detail: 'Internal server error',
+ });
+ vi.mocked(global.fetch).mockResolvedValue(mockErrorResponse);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe('Internal server error');
+ });
+
+ it('should handle server error with message field', async () => {
+ vi.useRealTimers();
+
+ const mockErrorResponse = createMockErrorResponse(400, {
+ message: 'Bad request',
+ });
+ vi.mocked(global.fetch).mockResolvedValue(mockErrorResponse);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe('Bad request');
+ });
+
+ it('should use default error message when response body is not JSON', async () => {
+ vi.useRealTimers();
+
+ const mockErrorResponse = {
+ ok: false,
+ status: 503,
+ json: vi.fn().mockRejectedValue(new Error('Not JSON')),
+ } as unknown as Response;
+ vi.mocked(global.fetch).mockResolvedValue(mockErrorResponse);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe('Server error: 503');
+ });
+
+ it('should handle timeout errors', async () => {
+ vi.useRealTimers();
+
+ const abortError = new Error('Abort');
+ abortError.name = 'AbortError';
+ vi.mocked(global.fetch).mockRejectedValue(abortError);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // AbortError during fetch should be ignored (not set as error state)
+ // Wait a bit to confirm no error is set
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // The hook catches AbortError and returns early without setting error
+ // So isLoading should still be true (no update happened)
+ expect(result.current.isLoading).toBe(true);
+ });
+ });
+
+ // ==========================================================================
+ // Refresh Function Tests
+ // ==========================================================================
+
+ describe('refresh function', () => {
+ it('should re-fetch providers when refresh() is called', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ result.current.refresh();
+ });
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('should not affect polling interval when refresh is called', async () => {
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Initial fetch
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Manual refresh at 15 seconds
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15000);
+ result.current.refresh();
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+
+ // Polling should still fire at original 30 second interval
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15000);
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(3);
+
+ // Next poll at 60 seconds
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30000);
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(4);
+ });
+
+ it('should update lastUpdated when refresh succeeds', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.lastUpdated).not.toBeNull();
+ });
+
+ const firstUpdate = result.current.lastUpdated!;
+
+ // Wait a bit, then refresh
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ await act(async () => {
+ result.current.refresh();
+ });
+
+ await waitFor(() => {
+ expect(result.current.lastUpdated!.getTime()).toBeGreaterThan(
+ firstUpdate.getTime()
+ );
+ });
+ });
+ });
+
+ // ==========================================================================
+ // Cleanup Tests
+ // ==========================================================================
+
+ describe('cleanup', () => {
+ it('should abort in-flight requests on unmount', async () => {
+ vi.useRealTimers();
+
+ const abortSpy = vi.fn();
+
+ // Create a mock AbortController
+ const originalAbortController = global.AbortController;
+ global.AbortController = class MockAbortController {
+ public signal = { aborted: false };
+ public abort(): void {
+ abortSpy();
+ this.signal.aborted = true;
+ }
+ } as unknown as typeof AbortController;
+
+ // Never-resolving fetch
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { unmount } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Unmount while request is in flight
+ unmount();
+
+ expect(abortSpy).toHaveBeenCalled();
+
+ // Restore original AbortController
+ global.AbortController = originalAbortController;
+ });
+
+ it('should clear polling interval on unmount', async () => {
+ const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { unmount } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Wait for initial fetch
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ unmount();
+
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+
+ it('should not update state after unmount', async () => {
+ vi.useRealTimers();
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ // Slow fetch that resolves after unmount
+ let resolvePromise: (value: Response) => void;
+ vi.mocked(global.fetch).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePromise = resolve;
+ })
+ );
+
+ const { unmount } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Unmount while fetch is in flight
+ unmount();
+
+ // Resolve the fetch after unmount
+ resolvePromise!(createMockResponse(createMockProvidersResponse()));
+
+ // Wait a bit
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ // Should not have React "can't update unmounted component" warnings
+ // The hook checks isMountedRef before updating state
+ expect(consoleSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining("Can't perform a React state update")
+ );
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ // ==========================================================================
+ // localStorage Edge Cases Tests
+ // ==========================================================================
+
+ describe('localStorage edge cases', () => {
+ it('should handle localStorage being unavailable', async () => {
+ vi.useRealTimers();
+
+ // Make localStorage throw
+ mockLocalStorage.getItem.mockImplementation(() => {
+ throw new Error('localStorage is disabled');
+ });
+ mockLocalStorage.setItem.mockImplementation(() => {
+ throw new Error('localStorage is disabled');
+ });
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Should still work, just warn
+ expect(result.current.providers).toHaveLength(1);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Unable to read from localStorage')
+ );
+
+ // Selecting provider should also work without throwing
+ act(() => {
+ result.current.selectProvider('gemini');
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Unable to write to localStorage')
+ );
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle invalid stored values', async () => {
+ vi.useRealTimers();
+
+ // Return empty string (invalid)
+ mockLocalStorage.getItem.mockReturnValue('');
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Empty string should be treated as null (no selection)
+ expect(result.current.selectedProvider).toBeNull();
+ });
+
+ it('should handle whitespace-only stored values', async () => {
+ vi.useRealTimers();
+
+ // Return whitespace-only string (invalid)
+ mockLocalStorage.getItem.mockReturnValue(' ');
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Whitespace-only string should be treated as null
+ expect(result.current.selectedProvider).toBeNull();
+ });
+
+ it('should handle null returned from localStorage.getItem', async () => {
+ vi.useRealTimers();
+
+ mockLocalStorage.getItem.mockReturnValue(null);
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.selectedProvider).toBeNull();
+ });
+ });
+
+ // ==========================================================================
+ // Request Cancellation Tests
+ // ==========================================================================
+
+ describe('request cancellation', () => {
+ it('should cancel previous request when refresh is called', async () => {
+ vi.useRealTimers();
+
+ const abortSpy = vi.fn();
+ let abortSignal: AbortSignal | undefined;
+
+ // Create mock that captures abort signal
+ const originalAbortController = global.AbortController;
+ global.AbortController = class MockAbortController {
+ public signal = {
+ aborted: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as unknown as AbortSignal;
+ public abort(): void {
+ abortSpy();
+ (this.signal as unknown as { aborted: boolean }).aborted = true;
+ }
+ } as unknown as typeof AbortController;
+
+ vi.mocked(global.fetch).mockImplementation((_, init) => {
+ abortSignal = init?.signal as AbortSignal;
+ return new Promise(() => {}); // Never resolves
+ });
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // First request is in flight
+ expect(abortSignal).toBeDefined();
+
+ // Trigger refresh
+ act(() => {
+ result.current.refresh();
+ });
+
+ // First request should be aborted
+ expect(abortSpy).toHaveBeenCalled();
+
+ // Restore original
+ global.AbortController = originalAbortController;
+ });
+
+ it('should cancel request when starting a new fetch', async () => {
+ vi.useRealTimers();
+
+ let abortCallCount = 0;
+
+ // Track abort calls
+ const originalAbortController = global.AbortController;
+ global.AbortController = class MockAbortController {
+ public signal = {
+ aborted: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as unknown as AbortSignal;
+ public abort(): void {
+ abortCallCount++;
+ (this.signal as unknown as { aborted: boolean }).aborted = true;
+ }
+ } as unknown as typeof AbortController;
+
+ vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Trigger multiple refreshes
+ act(() => {
+ result.current.refresh();
+ });
+ act(() => {
+ result.current.refresh();
+ });
+
+ // Each refresh should abort the previous request
+ expect(abortCallCount).toBeGreaterThanOrEqual(2);
+
+ // Restore original
+ global.AbortController = originalAbortController;
+ });
+ });
+
+ // ==========================================================================
+ // Return Value Stability Tests
+ // ==========================================================================
+
+ describe('return value stability', () => {
+ it('should return stable function references', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result, rerender } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const firstSelectProvider = result.current.selectProvider;
+ const firstRefresh = result.current.refresh;
+
+ // Re-render the hook
+ rerender();
+
+ // Functions should be stable (same references)
+ expect(result.current.selectProvider).toBe(firstSelectProvider);
+ expect(result.current.refresh).toBe(firstRefresh);
+ });
+
+ it('should return correct type for providers', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ const provider: ProviderStatus = result.current.providers[0];
+ expect(typeof provider.id).toBe('string');
+ expect(typeof provider.name).toBe('string');
+ expect(typeof provider.description).toBe('string');
+ expect(typeof provider.isAvailable).toBe('boolean');
+ expect(
+ provider.cooldownSeconds === null ||
+ typeof provider.cooldownSeconds === 'number'
+ ).toBe(true);
+ expect(typeof provider.totalRequestsRemaining).toBe('number');
+ });
+ });
+
+ // ==========================================================================
+ // Server-Side Rendering Safety Tests
+ // ==========================================================================
+
+ describe('server-side rendering safety', () => {
+ it('should handle window being undefined', async () => {
+ vi.useRealTimers();
+
+ // Simulate SSR by temporarily removing window
+ const originalWindow = global.window;
+
+ // Can't fully remove window in JSDOM, but we can test the hook
+ // still works with the mocked localStorage
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.providers).toHaveLength(1);
+
+ // Restore
+ global.window = originalWindow;
+ });
+ });
+
+ // ==========================================================================
+ // Error Message Edge Cases
+ // ==========================================================================
+
+ describe('error message edge cases', () => {
+ it('should handle non-Error objects thrown during fetch', async () => {
+ vi.useRealTimers();
+
+ // Throw a string instead of an Error object
+ vi.mocked(global.fetch).mockRejectedValue('Something went wrong');
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ // Should get the fallback error message
+ expect(result.current.error).toBe(
+ 'An unexpected error occurred while fetching provider status.'
+ );
+ });
+
+ it('should handle null thrown during fetch', async () => {
+ vi.useRealTimers();
+
+ // Throw null
+ vi.mocked(global.fetch).mockRejectedValue(null);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe(
+ 'An unexpected error occurred while fetching provider status.'
+ );
+ });
+
+ it('should handle undefined thrown during fetch', async () => {
+ vi.useRealTimers();
+
+ // Throw undefined
+ vi.mocked(global.fetch).mockRejectedValue(undefined);
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe(
+ 'An unexpected error occurred while fetching provider status.'
+ );
+ });
+
+ it('should handle object thrown during fetch', async () => {
+ vi.useRealTimers();
+
+ // Throw a plain object
+ vi.mocked(global.fetch).mockRejectedValue({ code: 500, msg: 'Error' });
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.error).toBe(
+ 'An unexpected error occurred while fetching provider status.'
+ );
+ });
+ });
+
+ // ==========================================================================
+ // Abort Controller Cleanup Edge Cases
+ // ==========================================================================
+
+ describe('abort controller cleanup edge cases', () => {
+ it('should handle multiple rapid refreshes without memory leaks', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {}) // Never resolves
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Rapid fire refreshes
+ for (let i = 0; i < 10; i++) {
+ act(() => {
+ result.current.refresh();
+ });
+ }
+
+ // Should still be in loading state without errors (hook handles rapid refreshes)
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('should properly clean up abort controller reference after successful fetch', async () => {
+ vi.useRealTimers();
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result, unmount } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Unmount after successful fetch - should not throw
+ expect(() => unmount()).not.toThrow();
+ });
+
+ it('should abort pending timeout on successful fetch', async () => {
+ vi.useRealTimers();
+
+ const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
+
+ vi.mocked(global.fetch).mockResolvedValue(
+ createMockResponse(createMockProvidersResponse())
+ );
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // clearTimeout should have been called to cancel the timeout
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+
+ clearTimeoutSpy.mockRestore();
+ });
+ });
+
+ // ==========================================================================
+ // Timeout Edge Cases
+ // ==========================================================================
+
+ describe('timeout edge cases', () => {
+ it('should abort request after timeout', async () => {
+ // Use fake timers for this test
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const abortSpy = vi.fn();
+
+ // Create mock AbortController
+ const originalAbortController = global.AbortController;
+ global.AbortController = class MockAbortController {
+ public signal = {
+ aborted: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as unknown as AbortSignal;
+ public abort(): void {
+ abortSpy();
+ (this.signal as unknown as { aborted: boolean }).aborted = true;
+ }
+ } as unknown as typeof AbortController;
+
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {}) // Never resolves
+ );
+
+ renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ // Advance time past the timeout (30000ms default)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(31000);
+ });
+
+ // Request should have been aborted due to timeout
+ expect(abortSpy).toHaveBeenCalled();
+
+ // Restore original
+ global.AbortController = originalAbortController;
+ });
+ });
+
+ // ==========================================================================
+ // Provider Model Edge Cases
+ // ==========================================================================
+
+ describe('provider model edge cases', () => {
+ it('should handle provider with empty models array', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: false,
+ models: [],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ expect(result.current.providers[0].cooldownSeconds).toBeNull();
+ expect(result.current.providers[0].totalRequestsRemaining).toBe(Infinity);
+ });
+
+ it('should handle mix of models with and without cooldowns', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [
+ {
+ provider: 'gemini',
+ is_available: true,
+ models: [
+ {
+ model_name: 'model-1',
+ is_available: true,
+ requests_remaining_minute: 10,
+ tokens_remaining_minute: 100000,
+ requests_remaining_day: 20,
+ tokens_remaining_day: null,
+ cooldown_seconds: null,
+ reason: null,
+ },
+ {
+ model_name: 'model-2',
+ is_available: false,
+ requests_remaining_minute: 0,
+ tokens_remaining_minute: 0,
+ requests_remaining_day: 0,
+ tokens_remaining_day: 0,
+ cooldown_seconds: 45,
+ reason: 'Rate limited',
+ },
+ ],
+ },
+ ],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.providers).toHaveLength(1);
+ });
+
+ // Should pick up the cooldown from the model that has one
+ expect(result.current.providers[0].cooldownSeconds).toBe(45);
+ });
+
+ it('should handle empty providers response', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockProvidersResponse({
+ providers: [],
+ });
+ vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockResponse));
+
+ const { result } = renderHook(() => useProviders(), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.providers).toEqual([]);
+ expect(result.current.error).toBeNull();
+ });
+ });
+});
diff --git a/frontend/src/hooks/__tests__/use-sse.test.ts b/frontend/src/hooks/__tests__/use-sse.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..51660b7d7358f086f208667ed5eda760a02d7754
--- /dev/null
+++ b/frontend/src/hooks/__tests__/use-sse.test.ts
@@ -0,0 +1,1365 @@
+/**
+ * Unit Tests for useSSE Hook
+ *
+ * Comprehensive test coverage for the SSE streaming hook.
+ * Tests connection lifecycle, token streaming, error handling,
+ * abort functionality, and cleanup.
+ *
+ * @module hooks/__tests__/use-sse.test
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useSSE } from '../use-sse';
+import type { SSEDoneResult, SSEError, ConnectionState, UseSSEReturn } from '../use-sse';
+
+// ============================================================================
+// Test Helpers
+// ============================================================================
+
+/**
+ * Create a mock SSE response stream from an array of event strings.
+ *
+ * Events should be in SSE format: 'data: {"type":"token","content":"..."}'
+ *
+ * @param events - Array of SSE event strings
+ * @param delayMs - Optional delay between events in milliseconds
+ * @returns ReadableStream that emits the events
+ */
+function createMockSSEResponse(
+ events: string[],
+ delayMs = 0
+): ReadableStream {
+ const encoder = new TextEncoder();
+ let index = 0;
+
+ return new ReadableStream({
+ async pull(controller) {
+ if (index < events.length) {
+ if (delayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ controller.enqueue(encoder.encode(events[index] + '\n\n'));
+ index++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+}
+
+/**
+ * Create a mock Response object with SSE body.
+ *
+ * @param events - Array of SSE event strings
+ * @param options - Override response options
+ * @returns Mock Response object
+ */
+function createMockResponse(
+ events: string[],
+ options: { ok?: boolean; status?: number; delayMs?: number } = {}
+): Response {
+ const { ok = true, status = 200, delayMs = 0 } = options;
+
+ return {
+ ok,
+ status,
+ body: createMockSSEResponse(events, delayMs),
+ json: vi.fn().mockResolvedValue({}),
+ } as unknown as Response;
+}
+
+/**
+ * Create token event SSE string.
+ */
+function tokenEvent(content: string): string {
+ return `data: {"type":"token","content":"${content}"}`;
+}
+
+/**
+ * Create done event SSE string with full response data.
+ */
+function doneEvent(
+ response: string,
+ options: {
+ sources?: Array<{
+ chunk_id: string;
+ text: string;
+ source: string;
+ page: number;
+ heading_path: string[];
+ score: number;
+ }>;
+ provider?: string;
+ model?: string;
+ latency_ms?: number;
+ } = {}
+): string {
+ const {
+ sources = [],
+ provider = 'test-provider',
+ model = 'test-model',
+ latency_ms = 100,
+ } = options;
+
+ return `data: ${JSON.stringify({
+ type: 'done',
+ response,
+ sources,
+ provider,
+ model,
+ latency_ms,
+ })}`;
+}
+
+/**
+ * Create error event SSE string.
+ */
+function errorEvent(message: string, retry_after?: number): string {
+ const payload: { type: string; message: string; retry_after?: number } = {
+ type: 'error',
+ message,
+ };
+ if (retry_after !== undefined) {
+ payload.retry_after = retry_after;
+ }
+ return `data: ${JSON.stringify(payload)}`;
+}
+
+/**
+ * Helper to wait for connection state to change.
+ */
+async function _waitForState(
+ result: { current: { connectionState: ConnectionState } },
+ expectedState: ConnectionState,
+ timeoutMs = 1000
+): Promise {
+ await waitFor(
+ () => {
+ expect(result.current.connectionState).toBe(expectedState);
+ },
+ { timeout: timeoutMs }
+ );
+}
+
+// ============================================================================
+// Test Suite
+// ============================================================================
+
+describe('useSSE', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // ==========================================================================
+ // Connection Lifecycle Tests
+ // ==========================================================================
+
+ describe('connection lifecycle', () => {
+ it('should start in disconnected state', () => {
+ const { result } = renderHook(() => useSSE());
+
+ expect(result.current.connectionState).toBe('disconnected');
+ expect(result.current.isStreaming).toBe(false);
+ });
+
+ it('should transition to connecting when startStream is called', async () => {
+ // Mock fetch to return a pending promise that never resolves
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {}) // Never resolves
+ );
+
+ const { result } = renderHook(() => useSSE());
+
+ act(() => {
+ result.current.startStream('test query');
+ });
+
+ expect(result.current.connectionState).toBe('connecting');
+ expect(result.current.isStreaming).toBe(true);
+ });
+
+ it('should transition to connected when first token arrives', async () => {
+ vi.useRealTimers();
+
+ const connectionStates: ConnectionState[] = [];
+ const onConnectionChange = vi.fn((state: ConnectionState) => {
+ connectionStates.push(state);
+ });
+
+ // Use a stream with delays to allow state updates between events
+ const encoder = new TextEncoder();
+ let eventIndex = 0;
+ const events = [
+ 'data: {"type":"token","content":"Hello"}\n\n',
+ 'data: {"type":"done","response":"Hello","sources":[],"provider":"test","model":"test","latency_ms":100}\n\n',
+ ];
+
+ const stream = new ReadableStream({
+ async pull(controller) {
+ if (eventIndex < events.length) {
+ // Add delay between events to allow React state updates
+ await new Promise((r) => setTimeout(r, 10));
+ controller.enqueue(encoder.encode(events[eventIndex]));
+ eventIndex++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+
+ const mockResponse: Partial = {
+ ok: true,
+ status: 200,
+ body: stream,
+ };
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
+
+ const { result } = renderHook(() => useSSE({ onConnectionChange }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(result.current.connectionState).toBe('disconnected');
+ });
+
+ // Verify connecting state was reached
+ expect(onConnectionChange).toHaveBeenCalledWith('connecting');
+ // Note: Due to React's state batching and the synchronous nature of
+ // stream processing, the 'connected' state may be skipped when events
+ // are processed quickly. This is expected behavior.
+ // The important thing is that we transition through connecting to disconnected.
+ expect(connectionStates).toContain('connecting');
+ expect(connectionStates[connectionStates.length - 1]).toBe('disconnected');
+ });
+
+ it('should transition to disconnected when done event received', async () => {
+ vi.useRealTimers();
+
+ const onConnectionChange = vi.fn();
+ const mockResponse = createMockResponse([
+ tokenEvent('Hello'),
+ doneEvent('Hello'),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onConnectionChange }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ // Verify final state is disconnected
+ await waitFor(() => {
+ expect(result.current.connectionState).toBe('disconnected');
+ });
+ expect(onConnectionChange).toHaveBeenCalledWith('disconnected');
+ });
+
+ it('should transition to error when error event received', async () => {
+ vi.useRealTimers();
+
+ const connectionStates: ConnectionState[] = [];
+ const onError = vi.fn();
+ const onConnectionChange = vi.fn((state: ConnectionState) => {
+ connectionStates.push(state);
+ });
+ const mockResponse = createMockResponse([errorEvent('Something went wrong')]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onConnectionChange, onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ // Wait for the error callback to be called
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalled();
+ });
+
+ // Verify error state transition occurred at some point
+ expect(onConnectionChange).toHaveBeenCalledWith('error');
+ expect(connectionStates).toContain('error');
+ });
+
+ it('should call onConnectionChange callback for each state change', async () => {
+ vi.useRealTimers();
+
+ const connectionStates: ConnectionState[] = [];
+ const onConnectionChange = vi.fn((state: ConnectionState) => {
+ connectionStates.push(state);
+ });
+ const mockResponse = createMockResponse([
+ tokenEvent('Hi'),
+ doneEvent('Hi'),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onConnectionChange }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(result.current.connectionState).toBe('disconnected');
+ });
+
+ // Should have received at least: connecting -> disconnected
+ // Note: 'connected' may or may not appear depending on timing
+ expect(onConnectionChange).toHaveBeenCalledWith('connecting');
+ expect(onConnectionChange).toHaveBeenCalledWith('disconnected');
+ expect(connectionStates[0]).toBe('disconnected'); // From abort() call in startStream
+ expect(connectionStates[1]).toBe('connecting');
+ expect(connectionStates[connectionStates.length - 1]).toBe('disconnected');
+ });
+ });
+
+ // ==========================================================================
+ // Token Streaming Tests
+ // ==========================================================================
+
+ describe('token streaming', () => {
+ it('should call onToken for each token event', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const mockResponse = createMockResponse([
+ tokenEvent('Hello'),
+ tokenEvent(' world'),
+ doneEvent('Hello world'),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onToken }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onToken).toHaveBeenCalledTimes(2);
+ });
+
+ expect(onToken).toHaveBeenNthCalledWith(1, 'Hello');
+ expect(onToken).toHaveBeenNthCalledWith(2, ' world');
+ });
+
+ it('should handle multiple tokens delivered in sequence', async () => {
+ vi.useRealTimers();
+
+ const tokens: string[] = [];
+ const onToken = vi.fn((content: string) => tokens.push(content));
+
+ const mockResponse = createMockResponse([
+ tokenEvent('The'),
+ tokenEvent(' quick'),
+ tokenEvent(' brown'),
+ tokenEvent(' fox'),
+ doneEvent('The quick brown fox'),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onToken }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(tokens).toHaveLength(4);
+ });
+
+ expect(tokens.join('')).toBe('The quick brown fox');
+ });
+
+ it('should handle empty token content gracefully', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const mockResponse = createMockResponse([
+ tokenEvent(''),
+ tokenEvent('Hello'),
+ tokenEvent(''),
+ doneEvent('Hello'),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onToken }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onToken).toHaveBeenCalledTimes(3);
+ });
+
+ // Empty tokens are still delivered
+ expect(onToken).toHaveBeenCalledWith('');
+ expect(onToken).toHaveBeenCalledWith('Hello');
+ });
+
+ it('should not call onToken when callback is not provided', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockResponse([
+ tokenEvent('Hello'),
+ doneEvent('Hello'),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE());
+
+ // Should not throw
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(result.current.connectionState).toBe('disconnected');
+ });
+ });
+ });
+
+ // ==========================================================================
+ // Completion Handling (done event) Tests
+ // ==========================================================================
+
+ describe('completion handling (done event)', () => {
+ it('should call onDone callback with correct response', async () => {
+ vi.useRealTimers();
+
+ const onDone = vi.fn();
+ const mockResponse = createMockResponse([
+ tokenEvent('Hello world'),
+ doneEvent('Hello world', {
+ provider: 'gemini',
+ model: 'gemini-pro',
+ latency_ms: 250,
+ }),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalledTimes(1);
+ });
+
+ const doneResult: SSEDoneResult = onDone.mock.calls[0][0];
+ expect(doneResult.response).toBe('Hello world');
+ expect(doneResult.provider).toBe('gemini');
+ expect(doneResult.model).toBe('gemini-pro');
+ expect(doneResult.latencyMs).toBe(250);
+ });
+
+ it('should transform sources from snake_case to camelCase', async () => {
+ vi.useRealTimers();
+
+ const onDone = vi.fn();
+ const mockResponse = createMockResponse([
+ tokenEvent('Response'),
+ doneEvent('Response', {
+ sources: [
+ {
+ chunk_id: 'chunk-1',
+ text: 'Source text content',
+ source: 'document.pdf',
+ page: 5,
+ heading_path: ['Chapter 1', 'Section 2', 'Subsection 3'],
+ score: 0.95,
+ },
+ {
+ chunk_id: 'chunk-2',
+ text: 'Another source',
+ source: 'other.pdf',
+ page: 10,
+ heading_path: ['Intro'],
+ score: 0.85,
+ },
+ ],
+ }),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalledTimes(1);
+ });
+
+ const doneResult: SSEDoneResult = onDone.mock.calls[0][0];
+ expect(doneResult.sources).toHaveLength(2);
+
+ // First source
+ expect(doneResult.sources[0]).toEqual({
+ id: 'chunk-1',
+ text: 'Source text content',
+ page: 5,
+ headingPath: 'Chapter 1 > Section 2 > Subsection 3',
+ score: 0.95,
+ });
+
+ // Second source
+ expect(doneResult.sources[1]).toEqual({
+ id: 'chunk-2',
+ text: 'Another source',
+ page: 10,
+ headingPath: 'Intro',
+ score: 0.85,
+ });
+ });
+
+ it('should handle empty sources array', async () => {
+ vi.useRealTimers();
+
+ const onDone = vi.fn();
+ const mockResponse = createMockResponse([
+ tokenEvent('Response'),
+ doneEvent('Response', { sources: [] }),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalledTimes(1);
+ });
+
+ const doneResult: SSEDoneResult = onDone.mock.calls[0][0];
+ expect(doneResult.sources).toEqual([]);
+ });
+
+ it('should correctly parse provider, model, and latencyMs', async () => {
+ vi.useRealTimers();
+
+ const onDone = vi.fn();
+ const mockResponse = createMockResponse([
+ doneEvent('Response', {
+ provider: 'deepseek',
+ model: 'deepseek-chat',
+ latency_ms: 1500,
+ }),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalledTimes(1);
+ });
+
+ const doneResult: SSEDoneResult = onDone.mock.calls[0][0];
+ expect(doneResult.provider).toBe('deepseek');
+ expect(doneResult.model).toBe('deepseek-chat');
+ expect(doneResult.latencyMs).toBe(1500);
+ });
+ });
+
+ // ==========================================================================
+ // Error Handling Tests
+ // ==========================================================================
+
+ describe('error handling', () => {
+ it('should trigger onError for network errors (fetch failure)', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const networkError = new TypeError('Failed to fetch');
+
+ vi.mocked(global.fetch).mockRejectedValue(networkError);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.isNetworkError).toBe(true);
+ expect(error.message).toContain('connect');
+ });
+
+ it('should trigger onError for server error responses (non-200)', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const mockResponse = {
+ ok: false,
+ status: 500,
+ json: vi.fn().mockResolvedValue({ detail: 'Internal server error' }),
+ } as unknown as Response;
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.message).toBe('Internal server error');
+ });
+
+ it('should handle server error response with message field', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const mockResponse = {
+ ok: false,
+ status: 400,
+ json: vi.fn().mockResolvedValue({ message: 'Bad request data' }),
+ } as unknown as Response;
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.message).toBe('Bad request data');
+ });
+
+ it('should use default error message when response body is not JSON', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const mockResponse = {
+ ok: false,
+ status: 503,
+ json: vi.fn().mockRejectedValue(new Error('Not JSON')),
+ } as unknown as Response;
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.message).toBe('Server error: 503');
+ });
+
+ it('should trigger onError with retry_after from SSE error event', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const mockResponse = createMockResponse([
+ errorEvent('Rate limited', 5000),
+ ]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.message).toBe('Rate limited');
+ expect(error.retryAfter).toBe(5000);
+ });
+
+ it('should set isNetworkError flag correctly for network errors', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const networkError = new TypeError('network connection failed');
+
+ vi.mocked(global.fetch).mockRejectedValue(networkError);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.isNetworkError).toBe(true);
+ });
+
+ it('should set isNetworkError to false for non-network errors', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const mockResponse = createMockResponse([errorEvent('Provider unavailable')]);
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.isNetworkError).toBe(false);
+ });
+
+ it('should handle response with no readable body', async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ body: null,
+ } as unknown as Response;
+
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ const error: SSEError = onError.mock.calls[0][0];
+ expect(error.message).toBe('Response body is not readable');
+ });
+ });
+
+ // ==========================================================================
+ // Abort Functionality Tests
+ // ==========================================================================
+
+ describe('abort functionality', () => {
+ it('should abort the current stream when abort() is called', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const onError = vi.fn();
+
+ // Create a slow stream that we can abort
+ let resolveStream: () => void;
+ const slowPromise = new Promise((resolve) => {
+ resolveStream = () =>
+ resolve(
+ createMockResponse([tokenEvent('Hello'), doneEvent('Hello')])
+ );
+ });
+
+ vi.mocked(global.fetch).mockReturnValue(slowPromise);
+
+ const { result } = renderHook(() => useSSE({ onToken, onError }));
+
+ act(() => {
+ result.current.startStream('test');
+ });
+
+ expect(result.current.connectionState).toBe('connecting');
+
+ act(() => {
+ result.current.abort();
+ });
+
+ expect(result.current.connectionState).toBe('disconnected');
+ expect(result.current.isStreaming).toBe(false);
+
+ // Resolve the stream after abort
+ resolveStream!();
+
+ // Should not have called onError for abort
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('should abort previous stream when starting a new one', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+
+ // First fetch never resolves
+ vi.mocked(global.fetch).mockImplementationOnce(
+ () => new Promise(() => {})
+ );
+
+ const { result } = renderHook(() => useSSE({ onToken }));
+
+ // Start first stream
+ act(() => {
+ result.current.startStream('first query');
+ });
+
+ expect(result.current.connectionState).toBe('connecting');
+
+ // Second stream with fast response
+ vi.mocked(global.fetch).mockResolvedValueOnce(
+ createMockResponse([tokenEvent('Second'), doneEvent('Second')])
+ );
+
+ // Start second stream (should abort first)
+ await act(async () => {
+ result.current.startStream('second query');
+ });
+
+ // Should have received tokens from second stream only
+ await waitFor(() => {
+ expect(onToken).toHaveBeenCalledWith('Second');
+ });
+ });
+
+ it("should not trigger onError callback when user aborts", async () => {
+ vi.useRealTimers();
+
+ const onError = vi.fn();
+
+ // Create AbortController to simulate abort
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {}) // Never resolves
+ );
+
+ const { result } = renderHook(() => useSSE({ onError }));
+
+ act(() => {
+ result.current.startStream('test');
+ });
+
+ act(() => {
+ result.current.abort();
+ });
+
+ // Wait a bit to ensure no error callback fires
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('should be safe to call abort when no stream is active', () => {
+ const { result } = renderHook(() => useSSE());
+
+ // Should not throw
+ expect(() => {
+ act(() => {
+ result.current.abort();
+ });
+ }).not.toThrow();
+
+ expect(result.current.connectionState).toBe('disconnected');
+ });
+ });
+
+ // ==========================================================================
+ // Cleanup Tests
+ // ==========================================================================
+
+ describe('cleanup', () => {
+ it('should abort stream on component unmount', async () => {
+ vi.useRealTimers();
+
+ const abortSpy = vi.fn();
+
+ // Create a mock AbortController that we can spy on
+ const originalAbortController = global.AbortController;
+ global.AbortController = class MockAbortController {
+ public signal = { aborted: false };
+ public abort(): void {
+ abortSpy();
+ this.signal.aborted = true;
+ }
+ } as unknown as typeof AbortController;
+
+ // Never-resolving fetch
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ const { result, unmount } = renderHook(() => useSSE());
+
+ act(() => {
+ result.current.startStream('test');
+ });
+
+ // Unmount the component
+ unmount();
+
+ expect(abortSpy).toHaveBeenCalled();
+
+ // Restore original AbortController
+ global.AbortController = originalAbortController;
+ });
+ });
+
+ // ==========================================================================
+ // Query Parameters Tests
+ // ==========================================================================
+
+ describe('query parameters', () => {
+ it('should send query in request body', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockResponse([doneEvent('Response')]);
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE());
+
+ await act(async () => {
+ result.current.startStream('What is PMV?');
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ query: 'What is PMV?', stream: true }),
+ })
+ );
+ });
+
+ it('should include provider in request body when specified', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockResponse([doneEvent('Response')]);
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE());
+
+ await act(async () => {
+ result.current.startStream('What is PMV?', 'gemini');
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: JSON.stringify({ query: 'What is PMV?', stream: true, provider: 'gemini' }),
+ })
+ );
+ });
+
+ it('should not include provider in body when not specified', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockResponse([doneEvent('Response')]);
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE());
+
+ await act(async () => {
+ result.current.startStream('test query');
+ });
+
+ const callArgs = vi.mocked(global.fetch).mock.calls[0];
+ const body = JSON.parse(callArgs[1]?.body as string);
+
+ expect(body).toEqual({ query: 'test query', stream: true });
+ expect(body.provider).toBeUndefined();
+ });
+
+ it('should set correct headers', async () => {
+ vi.useRealTimers();
+
+ const mockResponse = createMockResponse([doneEvent('Response')]);
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE());
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream',
+ },
+ })
+ );
+ });
+ });
+
+ // ==========================================================================
+ // Callback Ref Stability Tests
+ // ==========================================================================
+
+ describe('callback ref stability', () => {
+ it('should use latest callback when it changes during streaming', async () => {
+ vi.useRealTimers();
+
+ const firstOnToken = vi.fn();
+ const secondOnToken = vi.fn();
+
+ // Create a slow stream
+ const mockResponse = createMockResponse(
+ [tokenEvent('First'), tokenEvent('Second'), doneEvent('First Second')],
+ { delayMs: 10 } // Small delay between events
+ );
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result, rerender } = renderHook(
+ ({ onToken }) => useSSE({ onToken }),
+ { initialProps: { onToken: firstOnToken } }
+ );
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ // Change callback mid-stream
+ rerender({ onToken: secondOnToken });
+
+ await waitFor(() => {
+ // At least one callback should have been called
+ expect(firstOnToken.mock.calls.length + secondOnToken.mock.calls.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('should not recreate startStream when callbacks change', () => {
+ type Props = { onToken: (content: string) => void };
+ const { result, rerender } = renderHook(
+ ({ onToken }) => useSSE({ onToken }),
+ { initialProps: { onToken: vi.fn() } }
+ );
+
+ const _firstStartStream = result.current.startStream;
+
+ rerender({ onToken: vi.fn() });
+
+ // startStream should be stable (same reference) due to useCallback
+ // Note: This might fail if the hook's dependencies change
+ // The hook uses abort and connectionState as deps, so this test
+ // verifies the ref pattern is working
+ expect(typeof result.current.startStream).toBe('function');
+ });
+ });
+
+ // ==========================================================================
+ // isStreaming Derived State Tests
+ // ==========================================================================
+
+ describe('isStreaming derived state', () => {
+ it('should be true when connecting', async () => {
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ const { result } = renderHook(() => useSSE());
+
+ act(() => {
+ result.current.startStream('test');
+ });
+
+ expect(result.current.isStreaming).toBe(true);
+ expect(result.current.connectionState).toBe('connecting');
+ });
+
+ it('should be true when connected or connecting during active stream', async () => {
+ vi.useRealTimers();
+
+ // Create a stream that stays open with a token
+ const encoder = new TextEncoder();
+ let tokenSent = false;
+ const stream = new ReadableStream({
+ async pull(controller) {
+ if (!tokenSent) {
+ // Send token after a small delay
+ await new Promise((r) => setTimeout(r, 20));
+ controller.enqueue(encoder.encode('data: {"type":"token","content":"Hello"}\n\n'));
+ tokenSent = true;
+ }
+ // Don't close - stay open to keep streaming
+ await new Promise((r) => setTimeout(r, 1000));
+ },
+ });
+
+ const mockResponse: Partial = {
+ ok: true,
+ status: 200,
+ body: stream,
+ };
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
+
+ const { result } = renderHook(() => useSSE());
+
+ act(() => {
+ result.current.startStream('test');
+ });
+
+ // isStreaming should be true while in connecting state
+ expect(result.current.isStreaming).toBe(true);
+ expect(['connecting', 'connected']).toContain(result.current.connectionState);
+ });
+
+ it('should be false when disconnected', () => {
+ const { result } = renderHook(() => useSSE());
+
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.connectionState).toBe('disconnected');
+ });
+
+ it('should be false when in error state', async () => {
+ vi.useRealTimers();
+
+ const connectionStates: ConnectionState[] = [];
+ const onError = vi.fn();
+ const onConnectionChange = vi.fn((state: ConnectionState) => {
+ connectionStates.push(state);
+ });
+ const mockResponse = createMockResponse([errorEvent('Error occurred')]);
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useSSE({ onError, onConnectionChange }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ // Wait for error to be processed
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalled();
+ });
+
+ // Verify error state was reached
+ expect(connectionStates).toContain('error');
+ // isStreaming should be false when state is error or disconnected
+ expect(result.current.isStreaming).toBe(false);
+ });
+ });
+
+ // ==========================================================================
+ // SSE Parsing Edge Cases
+ // ==========================================================================
+
+ describe('SSE parsing edge cases', () => {
+ it('should handle SSE lines without data prefix gracefully', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const onDone = vi.fn();
+
+ // Mix valid and invalid lines
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('event: keepalive\n\n'));
+ controller.enqueue(encoder.encode('data: {"type":"token","content":"Valid"}\n\n'));
+ controller.enqueue(encoder.encode(': comment line\n\n'));
+ controller.enqueue(encoder.encode('data: {"type":"done","response":"Valid","sources":[],"provider":"test","model":"test","latency_ms":100}\n\n'));
+ controller.close();
+ },
+ });
+
+ const mockResponse: Partial = {
+ ok: true,
+ status: 200,
+ body: stream,
+ };
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
+
+ const { result } = renderHook(() => useSSE({ onToken, onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalled();
+ });
+
+ // Only valid token should be processed
+ expect(onToken).toHaveBeenCalledTimes(1);
+ expect(onToken).toHaveBeenCalledWith('Valid');
+ });
+
+ it('should handle empty data lines (keepalive) gracefully', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const onDone = vi.fn();
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data: \n\n')); // Empty data (keepalive)
+ controller.enqueue(encoder.encode('data: {"type":"token","content":"Hello"}\n\n'));
+ controller.enqueue(encoder.encode('data: {"type":"done","response":"Hello","sources":[],"provider":"test","model":"test","latency_ms":100}\n\n'));
+ controller.close();
+ },
+ });
+
+ const mockResponse: Partial = {
+ ok: true,
+ status: 200,
+ body: stream,
+ };
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
+
+ const { result } = renderHook(() => useSSE({ onToken, onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalled();
+ });
+
+ // Only the actual token should be processed
+ expect(onToken).toHaveBeenCalledTimes(1);
+ expect(onToken).toHaveBeenCalledWith('Hello');
+ });
+
+ it('should handle malformed JSON gracefully', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const onDone = vi.fn();
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data: {invalid json}\n\n'));
+ controller.enqueue(encoder.encode('data: {"type":"token","content":"Valid"}\n\n'));
+ controller.enqueue(encoder.encode('data: {"type":"done","response":"Valid","sources":[],"provider":"test","model":"test","latency_ms":100}\n\n'));
+ controller.close();
+ },
+ });
+
+ const mockResponse: Partial = {
+ ok: true,
+ status: 200,
+ body: stream,
+ };
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
+
+ const { result } = renderHook(() => useSSE({ onToken, onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalled();
+ });
+
+ // Should have logged warning for malformed JSON
+ expect(consoleSpy).toHaveBeenCalled();
+
+ // Valid events should still be processed
+ expect(onToken).toHaveBeenCalledWith('Valid');
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle chunked SSE data correctly', async () => {
+ vi.useRealTimers();
+
+ const onToken = vi.fn();
+ const onDone = vi.fn();
+
+ // Simulate chunked delivery where JSON is split across chunks
+ const encoder = new TextEncoder();
+ let chunkIndex = 0;
+ const chunks = [
+ 'data: {"type":"tok', // Partial line
+ 'en","content":"Hello"}\n\ndata: {"type":"done","response":"Hello","sources":[],"provider":"test","model":"test","latency_ms":100}\n\n',
+ ];
+
+ const stream = new ReadableStream({
+ pull(controller) {
+ if (chunkIndex < chunks.length) {
+ controller.enqueue(encoder.encode(chunks[chunkIndex]));
+ chunkIndex++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+
+ const mockResponse: Partial = {
+ ok: true,
+ status: 200,
+ body: stream,
+ };
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
+
+ const { result } = renderHook(() => useSSE({ onToken, onDone }));
+
+ await act(async () => {
+ result.current.startStream('test');
+ });
+
+ await waitFor(() => {
+ expect(onDone).toHaveBeenCalled();
+ });
+
+ expect(onToken).toHaveBeenCalledWith('Hello');
+ });
+ });
+});
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0bcdfa393f4e36eac94dee94f0eb71bc8ce4fe57
--- /dev/null
+++ b/frontend/src/hooks/index.ts
@@ -0,0 +1,97 @@
+/**
+ * Custom React Hooks Barrel Export
+ *
+ * Re-exports all custom hooks from the hooks module for
+ * convenient imports throughout the application.
+ *
+ * @module hooks
+ * @since 1.0.0
+ *
+ * @example
+ * // Import hooks
+ * import { useChat } from '@/hooks';
+ *
+ * @example
+ * // Use in a component
+ * import { useChat } from '@/hooks';
+ *
+ * function ChatPage() {
+ * const {
+ * messages,
+ * addMessage,
+ * updateLastMessage,
+ * isLoading,
+ * error,
+ * } = useChat();
+ *
+ * // ... component logic
+ * }
+ */
+
+/**
+ * useChat - Comprehensive chat state management hook.
+ *
+ * Manages message history, loading states, and error handling
+ * for the RAG chatbot interface.
+ *
+ * Features:
+ * - Message CRUD operations (add, update, remove, clear)
+ * - Streaming support with functional updates
+ * - Loading and error state management
+ * - Optional persistence callback
+ *
+ * @see {@link ./use-chat} for full documentation
+ */
+export { useChat } from './use-chat';
+export type {
+ UseChatOptions,
+ UseChatReturn,
+ AddMessageOptions,
+ UpdateMessageOptions,
+} from './use-chat';
+
+/**
+ * useSSE - Server-Sent Events streaming hook.
+ *
+ * Manages SSE connections to the query endpoint for streaming
+ * LLM responses with proper connection lifecycle management.
+ *
+ * Features:
+ * - POST-based SSE using fetch with ReadableStream
+ * - Automatic connection state tracking
+ * - Proper cleanup on unmount
+ * - Timeout handling with AbortController
+ * - Stale closure prevention with callback refs
+ *
+ * @see {@link ./use-sse} for full documentation
+ */
+export { useSSE } from './use-sse';
+export type {
+ UseSSEOptions,
+ UseSSEReturn,
+ SSEDoneResult,
+ SSEError,
+ ConnectionState,
+} from './use-sse';
+
+/**
+ * useProviders - LLM provider status management hook.
+ *
+ * Fetches and manages LLM provider availability status with
+ * periodic polling and localStorage persistence for selection.
+ *
+ * Features:
+ * - Periodic polling of provider status endpoint
+ * - localStorage persistence for selected provider
+ * - Graceful error handling with automatic recovery
+ * - Aggregated availability information per provider
+ * - Manual refresh capability
+ *
+ * @see {@link ./use-providers} for full documentation
+ */
+export { useProviders } from './use-providers';
+export type { UseProvidersReturn, ProviderStatus } from './use-providers';
+
+// Future exports (to be implemented in subsequent steps):
+// export * from './use-local-storage';
+// export * from './use-scroll-to-bottom';
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b097dbf1a7a016475dbeca65ac189ed427a10fa7
--- /dev/null
+++ b/frontend/src/hooks/use-chat.ts
@@ -0,0 +1,610 @@
+/**
+ * useChat Hook
+ *
+ * A comprehensive React hook for managing chat message state in the
+ * RAG chatbot application. Provides all necessary state and actions
+ * for handling user/assistant messages, streaming updates, and errors.
+ *
+ * @module hooks/use-chat
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage in a chat component
+ * function ChatComponent() {
+ * const {
+ * messages,
+ * isLoading,
+ * error,
+ * addMessage,
+ * updateLastMessage,
+ * clearMessages,
+ * } = useChat();
+ *
+ * const handleSubmit = async (content: string) => {
+ * // Add user message
+ * addMessage('user', content);
+ *
+ * // Add placeholder for assistant response
+ * addMessage('assistant', '', { isStreaming: true });
+ *
+ * // Stream response...
+ * for await (const token of streamResponse(content)) {
+ * updateLastMessage(prev => prev + token);
+ * }
+ *
+ * // Mark streaming complete
+ * updateLastMessage(content, { isStreaming: false });
+ * };
+ *
+ * return (
+ *
+ * {messages.map(msg => )}
+ * {error && }
+ *
+ *
+ * );
+ * }
+ *
+ * @example
+ * // With initial messages (e.g., restored from localStorage)
+ * const { messages } = useChat({
+ * initialMessages: savedMessages,
+ * });
+ *
+ * @example
+ * // With loading state management
+ * const { setIsLoading, isLoading } = useChat();
+ *
+ * async function handleQuery() {
+ * setIsLoading(true);
+ * try {
+ * await fetchResponse();
+ * } finally {
+ * setIsLoading(false);
+ * }
+ * }
+ */
+
+'use client';
+
+import { useCallback, useState, useMemo } from 'react';
+import { generateId } from '@/lib/utils';
+import type { Message, Source } from '@/types';
+
+/**
+ * Options for the useChat hook initialization.
+ *
+ * Allows customization of initial state and behavior.
+ */
+export interface UseChatOptions {
+ /**
+ * Initial messages to populate the chat history.
+ * Useful for restoring conversation state from storage.
+ *
+ * @default []
+ */
+ initialMessages?: Message[];
+
+ /**
+ * Callback fired whenever messages change.
+ * Useful for persisting messages to localStorage or analytics.
+ *
+ * @param messages - The updated messages array
+ */
+ onMessagesChange?: (messages: Message[]) => void;
+}
+
+/**
+ * Options when adding a new message.
+ *
+ * Allows setting additional properties beyond role and content.
+ */
+export interface AddMessageOptions {
+ /**
+ * Whether the message is currently being streamed.
+ * Typically true for assistant messages during generation.
+ *
+ * @default false
+ */
+ isStreaming?: boolean;
+
+ /**
+ * Source citations for the message.
+ * Typically set after RAG retrieval completes.
+ */
+ sources?: Source[];
+
+ /**
+ * Custom timestamp for the message.
+ * Defaults to current time if not provided.
+ */
+ timestamp?: Date;
+
+ /**
+ * Custom ID for the message.
+ * Auto-generated if not provided.
+ */
+ id?: string;
+}
+
+/**
+ * Options when updating the last message.
+ *
+ * Allows modifying metadata alongside content.
+ */
+export interface UpdateMessageOptions {
+ /**
+ * Update the streaming state.
+ * Set to false when streaming completes.
+ */
+ isStreaming?: boolean;
+
+ /**
+ * Add or update source citations.
+ */
+ sources?: Source[];
+}
+
+/**
+ * Return type for the useChat hook.
+ *
+ * Provides all state values and action functions needed
+ * for managing chat functionality.
+ */
+export interface UseChatReturn {
+ /**
+ * Array of all messages in the conversation.
+ * Ordered chronologically (oldest first).
+ */
+ messages: Message[];
+
+ /**
+ * Whether the chat is currently processing a request.
+ * True during API calls and streaming.
+ */
+ isLoading: boolean;
+
+ /**
+ * Current error message, if any.
+ * Null when there's no error.
+ */
+ error: string | null;
+
+ /**
+ * The most recent message in the conversation.
+ * Undefined if no messages exist.
+ */
+ lastMessage: Message | undefined;
+
+ /**
+ * Total count of messages in the conversation.
+ */
+ messageCount: number;
+
+ /**
+ * Add a new message to the conversation.
+ *
+ * @param role - The message sender ('user' or 'assistant')
+ * @param content - The text content of the message
+ * @param options - Additional message options (isStreaming, sources, etc.)
+ * @returns The ID of the newly created message
+ */
+ addMessage: (
+ role: Message['role'],
+ content: string,
+ options?: AddMessageOptions
+ ) => string;
+
+ /**
+ * Update the content of the last message.
+ *
+ * Useful for streaming updates where content is appended incrementally.
+ * Can accept either a new content string or an updater function.
+ *
+ * @param contentOrUpdater - New content string or function receiving current content
+ * @param options - Optional metadata updates (isStreaming, sources)
+ */
+ updateLastMessage: (
+ contentOrUpdater: string | ((currentContent: string) => string),
+ options?: UpdateMessageOptions
+ ) => void;
+
+ /**
+ * Update a specific message by ID.
+ *
+ * @param messageId - The ID of the message to update
+ * @param updates - Partial message updates to apply
+ */
+ updateMessage: (
+ messageId: string,
+ updates: Partial>
+ ) => void;
+
+ /**
+ * Remove a message by ID.
+ *
+ * @param messageId - The ID of the message to remove
+ */
+ removeMessage: (messageId: string) => void;
+
+ /**
+ * Clear all messages from the conversation.
+ * Resets to an empty state.
+ */
+ clearMessages: () => void;
+
+ /**
+ * Set the loading state.
+ *
+ * @param loading - Whether the chat is loading
+ */
+ setIsLoading: (loading: boolean) => void;
+
+ /**
+ * Set an error message.
+ *
+ * @param errorMessage - The error message to display
+ */
+ setError: (errorMessage: string) => void;
+
+ /**
+ * Clear the current error.
+ */
+ clearError: () => void;
+}
+
+/**
+ * useChat Hook
+ *
+ * A comprehensive state management hook for chat functionality.
+ *
+ * @remarks
+ * ## State Management
+ *
+ * This hook manages three primary pieces of state:
+ * - **messages**: Array of Message objects representing the conversation
+ * - **isLoading**: Boolean indicating if the chat is processing
+ * - **error**: String error message or null
+ *
+ * ## Message Operations
+ *
+ * ### Adding Messages
+ * Use `addMessage` to add new messages to the conversation:
+ * ```ts
+ * // Add user message
+ * addMessage('user', 'What is PMV?');
+ *
+ * // Add assistant message with streaming state
+ * addMessage('assistant', '', { isStreaming: true });
+ * ```
+ *
+ * ### Streaming Updates
+ * Use `updateLastMessage` for efficient streaming:
+ * ```ts
+ * // Append content during streaming
+ * updateLastMessage(prev => prev + newToken);
+ *
+ * // Mark streaming complete and add sources
+ * updateLastMessage(finalContent, {
+ * isStreaming: false,
+ * sources: retrievedSources,
+ * });
+ * ```
+ *
+ * ### Updating Specific Messages
+ * Use `updateMessage` when you need to update a specific message:
+ * ```ts
+ * updateMessage(messageId, { sources: newSources });
+ * ```
+ *
+ * ## Performance Optimizations
+ *
+ * - Uses `useCallback` for stable action references
+ * - Provides memoized derived values (lastMessage, messageCount)
+ * - Supports functional updates for content (prevents stale closures)
+ *
+ * ## Integration with SSE Streaming
+ *
+ * This hook is designed to work seamlessly with Server-Sent Events:
+ *
+ * ```ts
+ * const { addMessage, updateLastMessage, setError, setIsLoading } = useChat();
+ *
+ * async function handleQuery(query: string) {
+ * addMessage('user', query);
+ * const messageId = addMessage('assistant', '', { isStreaming: true });
+ * setIsLoading(true);
+ *
+ * try {
+ * for await (const event of sseStream('/api/query', { query })) {
+ * if (event.type === 'token') {
+ * updateLastMessage(prev => prev + event.token);
+ * } else if (event.type === 'sources') {
+ * updateMessage(messageId, { sources: event.sources });
+ * } else if (event.type === 'error') {
+ * setError(event.error);
+ * }
+ * }
+ * } finally {
+ * updateLastMessage(content => content, { isStreaming: false });
+ * setIsLoading(false);
+ * }
+ * }
+ * ```
+ *
+ * @param options - Configuration options for the hook
+ * @returns Object containing state values and action functions
+ *
+ * @see {@link UseChatOptions} for initialization options
+ * @see {@link UseChatReturn} for return type documentation
+ */
+export function useChat(options: UseChatOptions = {}): UseChatReturn {
+ const { initialMessages = [], onMessagesChange } = options;
+
+ /**
+ * Core state for the chat conversation.
+ *
+ * Messages are stored as an array, ordered chronologically.
+ * New messages are appended to the end of the array.
+ */
+ const [messages, setMessages] = useState(initialMessages);
+
+ /**
+ * Loading state indicating if the chat is processing.
+ *
+ * Should be true during:
+ * - API requests
+ * - SSE streaming
+ * - Any async operation
+ */
+ const [isLoading, setIsLoading] = useState(false);
+
+ /**
+ * Error state for displaying error messages.
+ *
+ * Null when there's no error.
+ * Set via setError, cleared via clearError.
+ */
+ const [error, setErrorState] = useState(null);
+
+ /**
+ * Derived state: the most recent message.
+ *
+ * Memoized to prevent recalculation on every render.
+ * Useful for streaming updates to the current message.
+ */
+ const lastMessage = useMemo(() => messages[messages.length - 1], [messages]);
+
+ /**
+ * Derived state: total message count.
+ *
+ * Useful for UI elements like "X messages in conversation".
+ */
+ const messageCount = messages.length;
+
+ /**
+ * Internal helper to update messages with change callback.
+ *
+ * Wraps setMessages to also fire the onMessagesChange callback
+ * when messages are updated, enabling persistence/analytics.
+ */
+ const updateMessagesWithCallback = useCallback(
+ (updater: (prev: Message[]) => Message[]) => {
+ setMessages((prev) => {
+ const next = updater(prev);
+ // Fire callback if provided (async to not block render)
+ if (onMessagesChange) {
+ queueMicrotask(() => onMessagesChange(next));
+ }
+ return next;
+ });
+ },
+ [onMessagesChange]
+ );
+
+ /**
+ * Add a new message to the conversation.
+ *
+ * Creates a new Message object with:
+ * - Auto-generated ID (using generateId from utils)
+ * - Current timestamp (or provided timestamp)
+ * - Specified role and content
+ * - Optional isStreaming and sources
+ *
+ * @returns The ID of the newly created message
+ */
+ const addMessage = useCallback(
+ (
+ role: Message['role'],
+ content: string,
+ options: AddMessageOptions = {}
+ ): string => {
+ const {
+ isStreaming = false,
+ sources,
+ timestamp = new Date(),
+ id = generateId('msg'),
+ } = options;
+
+ const newMessage: Message = {
+ id,
+ role,
+ content,
+ timestamp,
+ isStreaming,
+ sources,
+ };
+
+ updateMessagesWithCallback((prev) => [...prev, newMessage]);
+
+ return id;
+ },
+ [updateMessagesWithCallback]
+ );
+
+ /**
+ * Update the content of the last message.
+ *
+ * Supports two usage patterns:
+ *
+ * 1. Direct content replacement:
+ * updateLastMessage('New content');
+ *
+ * 2. Functional update (for streaming):
+ * updateLastMessage(prev => prev + newToken);
+ *
+ * The functional pattern prevents stale closure issues when
+ * called rapidly during streaming updates.
+ */
+ const updateLastMessage = useCallback(
+ (
+ contentOrUpdater: string | ((currentContent: string) => string),
+ options: UpdateMessageOptions = {}
+ ): void => {
+ const { isStreaming, sources } = options;
+
+ updateMessagesWithCallback((prev) => {
+ // Guard against empty messages array
+ if (prev.length === 0) {
+ console.warn('updateLastMessage called with no messages');
+ return prev;
+ }
+
+ // Create a copy of the array with updated last message
+ const updated = [...prev];
+ const lastIndex = updated.length - 1;
+ const lastMsg = updated[lastIndex];
+
+ // Calculate new content
+ const newContent =
+ typeof contentOrUpdater === 'function'
+ ? contentOrUpdater(lastMsg.content)
+ : contentOrUpdater;
+
+ // Create updated message with new properties
+ updated[lastIndex] = {
+ ...lastMsg,
+ content: newContent,
+ // Only update optional fields if explicitly provided
+ ...(isStreaming !== undefined && { isStreaming }),
+ ...(sources !== undefined && { sources }),
+ };
+
+ return updated;
+ });
+ },
+ [updateMessagesWithCallback]
+ );
+
+ /**
+ * Update a specific message by ID.
+ *
+ * Useful when you need to update a message that isn't the last one,
+ * or when you have the message ID from addMessage's return value.
+ */
+ const updateMessage = useCallback(
+ (
+ messageId: string,
+ updates: Partial>
+ ): void => {
+ updateMessagesWithCallback((prev) => {
+ const index = prev.findIndex((msg) => msg.id === messageId);
+
+ // Guard against invalid message ID
+ if (index === -1) {
+ console.warn(`updateMessage: message with ID ${messageId} not found`);
+ return prev;
+ }
+
+ // Create updated array with modified message
+ const updated = [...prev];
+ updated[index] = {
+ ...updated[index],
+ ...updates,
+ };
+
+ return updated;
+ });
+ },
+ [updateMessagesWithCallback]
+ );
+
+ /**
+ * Remove a message by ID.
+ *
+ * Filters out the message with the matching ID.
+ * No-op if the message doesn't exist.
+ */
+ const removeMessage = useCallback(
+ (messageId: string): void => {
+ updateMessagesWithCallback((prev) =>
+ prev.filter((msg) => msg.id !== messageId)
+ );
+ },
+ [updateMessagesWithCallback]
+ );
+
+ /**
+ * Clear all messages from the conversation.
+ *
+ * Resets the messages array to empty.
+ * Useful for "New Conversation" functionality.
+ */
+ const clearMessages = useCallback((): void => {
+ updateMessagesWithCallback(() => []);
+ }, [updateMessagesWithCallback]);
+
+ /**
+ * Set an error message.
+ *
+ * Displays the error to the user.
+ * Should be paired with clearError when the error is dismissed.
+ */
+ const setError = useCallback((errorMessage: string): void => {
+ setErrorState(errorMessage);
+ }, []);
+
+ /**
+ * Clear the current error.
+ *
+ * Sets error state back to null.
+ * Call this when the user dismisses an error message.
+ */
+ const clearError = useCallback((): void => {
+ setErrorState(null);
+ }, []);
+
+ /**
+ * Return all state and actions as a single object.
+ *
+ * This pattern allows consumers to destructure only what they need
+ * while maintaining a stable API.
+ */
+ return {
+ // State
+ messages,
+ isLoading,
+ error,
+ lastMessage,
+ messageCount,
+
+ // Message actions
+ addMessage,
+ updateLastMessage,
+ updateMessage,
+ removeMessage,
+ clearMessages,
+
+ // Loading actions
+ setIsLoading,
+
+ // Error actions
+ setError,
+ clearError,
+ };
+}
+
+/**
+ * Type export for consumers who need to type their own state.
+ */
+export type { Message };
diff --git a/frontend/src/hooks/use-providers.ts b/frontend/src/hooks/use-providers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c3045151b14119d6944a5aa1cb4cb66422c932ec
--- /dev/null
+++ b/frontend/src/hooks/use-providers.ts
@@ -0,0 +1,80 @@
+/**
+ * useProviders Hook
+ *
+ * A React hook for accessing shared LLM provider state.
+ * This hook is a thin wrapper around the ProviderContext,
+ * ensuring all components share the same provider state.
+ *
+ * @module hooks/use-providers
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage - fetch and display provider status
+ * function ProviderSelector() {
+ * const {
+ * providers,
+ * selectedProvider,
+ * selectProvider,
+ * isLoading,
+ * error,
+ * } = useProviders();
+ *
+ * if (isLoading) return Loading providers...
;
+ * if (error) return Error: {error}
;
+ *
+ * return (
+ *
+ * selectProvider(null)}>
+ * Auto (default)
+ *
+ * {providers.map(provider => (
+ * selectProvider(provider.id)}
+ * disabled={!provider.isAvailable}
+ * >
+ * {provider.name}
+ * {provider.id === selectedProvider && ' (selected)'}
+ *
+ * ))}
+ *
+ * );
+ * }
+ */
+
+'use client';
+
+import { useProviderContext } from '@/contexts/provider-context';
+import type {
+ ProviderStatus,
+ ProviderContextValue,
+} from '@/contexts/provider-context';
+
+/**
+ * Re-export types for backward compatibility.
+ */
+export type { ProviderStatus };
+
+/**
+ * Return type for the useProviders hook.
+ */
+export type UseProvidersReturn = ProviderContextValue;
+
+/**
+ * useProviders Hook
+ *
+ * Access shared LLM provider state from the ProviderContext.
+ * All components using this hook share the same state, so when
+ * one component selects a provider, all others update immediately.
+ *
+ * @remarks
+ * This hook requires the ProviderProvider to be present in the
+ * component tree (typically in app/layout.tsx).
+ *
+ * @returns Object with provider status, selection, and control methods
+ *
+ * @throws Error if used outside ProviderProvider
+ */
+export function useProviders(): UseProvidersReturn {
+ return useProviderContext();
+}
diff --git a/frontend/src/hooks/use-sse.ts b/frontend/src/hooks/use-sse.ts
new file mode 100644
index 0000000000000000000000000000000000000000..798a98fd0918e3b07d5262e2108a7a5e095499df
--- /dev/null
+++ b/frontend/src/hooks/use-sse.ts
@@ -0,0 +1,855 @@
+/**
+ * useSSE Hook
+ *
+ * A production-ready React hook for managing Server-Sent Events (SSE)
+ * streaming connections to the RAG chatbot backend. Handles connection
+ * lifecycle, error recovery, and proper cleanup.
+ *
+ * @module hooks/use-sse
+ * @since 1.0.0
+ *
+ * @example
+ * // Basic usage with token streaming
+ * function ChatComponent() {
+ * const [response, setResponse] = useState('');
+ *
+ * const { startStream, abort, isStreaming, connectionState } = useSSE({
+ * onToken: (content) => {
+ * setResponse(prev => prev + content);
+ * },
+ * onDone: (result) => {
+ * console.log('Completed:', result.provider, result.model);
+ * console.log('Sources:', result.sources);
+ * },
+ * onError: (error) => {
+ * console.error('Stream error:', error.message);
+ * if (error.retryAfter) {
+ * console.log(`Retry after ${error.retryAfter}ms`);
+ * }
+ * },
+ * onConnectionChange: (state) => {
+ * console.log('Connection state:', state);
+ * },
+ * });
+ *
+ * const handleSubmit = (query: string) => {
+ * setResponse('');
+ * startStream(query);
+ * };
+ *
+ * return (
+ *
+ *
startStream('What is PMV?')}>
+ * Ask
+ *
+ *
+ * Cancel
+ *
+ *
Status: {connectionState}
+ *
{response}
+ *
+ * );
+ * }
+ *
+ * @example
+ * // With provider selection
+ * const { startStream } = useSSE({ onToken: handleToken });
+ *
+ * // Use specific provider
+ * startStream('Explain adaptive comfort', 'gemini');
+ *
+ * @example
+ * // Integration with useChat hook
+ * const { addMessage, updateLastMessage, setError, setIsLoading } = useChat();
+ *
+ * const { startStream, abort } = useSSE({
+ * onToken: (content) => {
+ * updateLastMessage(prev => prev + content);
+ * },
+ * onDone: (result) => {
+ * updateLastMessage(result.response, {
+ * isStreaming: false,
+ * sources: result.sources,
+ * });
+ * setIsLoading(false);
+ * },
+ * onError: (error) => {
+ * setError(error.message);
+ * setIsLoading(false);
+ * },
+ * });
+ *
+ * function handleQuery(query: string) {
+ * addMessage('user', query);
+ * addMessage('assistant', '', { isStreaming: true });
+ * setIsLoading(true);
+ * startStream(query);
+ * }
+ */
+
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { API_CONFIG } from '@/config/constants';
+import type { HistoryMessage, Source } from '@/types';
+
+// ============================================================================
+// Type Definitions
+// ============================================================================
+
+/**
+ * Source information from the backend SSE 'done' event.
+ *
+ * This represents the raw format from the backend before transformation
+ * to the frontend Source type.
+ *
+ * @internal
+ */
+interface BackendSourceInfo {
+ /** Unique chunk identifier from the retriever */
+ chunk_id: string;
+ /** Text content of the retrieved chunk */
+ text: string;
+ /** Source filename (e.g., "standard.pdf") */
+ source: string;
+ /** Page number in the original document */
+ page: number;
+ /** Document hierarchy path (e.g., ["Chapter 1", "Section 2"]) */
+ heading_path: string[];
+ /** Relevance score from the retriever */
+ score: number;
+}
+
+/**
+ * Result payload from a successful SSE stream completion.
+ *
+ * Contains the full response, source citations, and metadata
+ * about the LLM provider that generated the response.
+ */
+export interface SSEDoneResult {
+ /** Complete response text */
+ response: string;
+ /** Source citations from RAG retrieval */
+ sources: Source[];
+ /** LLM provider that generated the response */
+ provider: string;
+ /** Specific model used */
+ model: string;
+ /** Total latency in milliseconds */
+ latencyMs: number;
+}
+
+/**
+ * Error information from an SSE error event or connection failure.
+ *
+ * Provides structured error information for appropriate UI handling.
+ */
+export interface SSEError {
+ /** Human-readable error message */
+ message: string;
+ /**
+ * Milliseconds to wait before retrying.
+ * Set when the server indicates rate limiting or temporary unavailability.
+ */
+ retryAfter?: number;
+ /**
+ * Whether this error is due to network connectivity issues.
+ * Useful for showing appropriate UI (e.g., "Check your connection").
+ */
+ isNetworkError: boolean;
+}
+
+/**
+ * Connection state for the SSE stream.
+ *
+ * Tracks the lifecycle of the connection for UI feedback.
+ */
+export type ConnectionState =
+ | 'disconnected' // No active connection
+ | 'connecting' // Fetch in progress, waiting for response
+ | 'connected' // Receiving token events
+ | 'error'; // Connection failed or error event received
+
+/**
+ * Callback options for the useSSE hook.
+ *
+ * All callbacks are optional and use refs internally to prevent
+ * stale closure issues during streaming.
+ */
+export interface UseSSEOptions {
+ /**
+ * Called when a token chunk is received during streaming.
+ *
+ * Tokens are partial text fragments that should be appended
+ * to build the complete response progressively.
+ *
+ * @param content - The token text content
+ */
+ onToken?: (content: string) => void;
+
+ /**
+ * Called when the stream completes successfully.
+ *
+ * Provides the complete response, sources, and metadata.
+ * Use this to finalize the message state.
+ *
+ * @param result - Complete result with response, sources, and metadata
+ */
+ onDone?: (result: SSEDoneResult) => void;
+
+ /**
+ * Called when an error occurs during streaming.
+ *
+ * Can be triggered by:
+ * - Network errors (fetch failure)
+ * - Server error events
+ * - Parse errors
+ * - Timeout
+ *
+ * @param error - Error information with message and metadata
+ */
+ onError?: (error: SSEError) => void;
+
+ /**
+ * Called when connection state changes.
+ *
+ * Useful for showing connection status indicators in the UI.
+ *
+ * @param state - New connection state
+ */
+ onConnectionChange?: (state: ConnectionState) => void;
+}
+
+/**
+ * Return type for the useSSE hook.
+ *
+ * Provides methods to control the SSE connection and
+ * read-only state for UI rendering.
+ */
+export interface UseSSEReturn {
+ /**
+ * Start an SSE stream for a query.
+ *
+ * Initiates a POST request to the query endpoint and
+ * begins processing the SSE response stream.
+ *
+ * If a stream is already active, it will be aborted first.
+ *
+ * @param query - The user's question to send to the backend
+ * @param provider - Optional preferred LLM provider
+ * @param history - Optional conversation history for multi-turn context
+ */
+ startStream: (
+ query: string,
+ provider?: string,
+ history?: HistoryMessage[]
+ ) => void;
+
+ /**
+ * Abort the current stream.
+ *
+ * Cancels any ongoing fetch request and cleans up resources.
+ * Safe to call even if no stream is active.
+ */
+ abort: () => void;
+
+ /**
+ * Current connection state.
+ *
+ * Use this for UI feedback about connection status.
+ */
+ connectionState: ConnectionState;
+
+ /**
+ * Whether currently streaming.
+ *
+ * Convenience derived state - true when connectionState
+ * is 'connecting' or 'connected'.
+ */
+ isStreaming: boolean;
+}
+
+// ============================================================================
+// SSE Event Types (internal)
+// ============================================================================
+
+/**
+ * Token event - partial text received during streaming.
+ * @internal
+ */
+interface SSETokenEvent {
+ type: 'token';
+ content: string;
+}
+
+/**
+ * Done event - stream completed successfully.
+ * @internal
+ */
+interface SSEDoneEvent {
+ type: 'done';
+ response: string;
+ sources: BackendSourceInfo[];
+ provider: string;
+ model: string;
+ latency_ms: number;
+}
+
+/**
+ * Error event - server-side error during streaming.
+ * @internal
+ */
+interface SSEErrorEvent {
+ type: 'error';
+ message: string;
+ retry_after?: number;
+}
+
+/**
+ * Union type for all SSE event types.
+ * @internal
+ */
+type SSEEvent = SSETokenEvent | SSEDoneEvent | SSEErrorEvent;
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Transform backend source format to frontend Source type.
+ *
+ * Converts snake_case fields to camelCase and formats the
+ * heading path for display.
+ *
+ * @param backendSource - Source in backend format
+ * @returns Source in frontend format
+ * @internal
+ */
+function transformSource(backendSource: BackendSourceInfo): Source {
+ return {
+ id: backendSource.chunk_id,
+ text: backendSource.text,
+ page: backendSource.page,
+ // Join heading path with ' > ' for display
+ headingPath: backendSource.heading_path.join(' > '),
+ score: backendSource.score,
+ };
+}
+
+/**
+ * Parse a single SSE data line into an event object.
+ *
+ * SSE lines are in the format: `data: {"type": "...", ...}`
+ * This function extracts and parses the JSON payload.
+ *
+ * @param line - A single line from the SSE stream
+ * @returns Parsed event or null if line is not a data line
+ * @throws SyntaxError if JSON parsing fails
+ * @internal
+ */
+function parseSSELine(line: string): SSEEvent | null {
+ // SSE data lines start with "data: "
+ const dataPrefix = 'data: ';
+ if (!line.startsWith(dataPrefix)) {
+ return null;
+ }
+
+ // Extract JSON payload after the prefix
+ const jsonStr = line.slice(dataPrefix.length);
+
+ // Empty data line (keepalive) - ignore
+ if (!jsonStr.trim()) {
+ return null;
+ }
+
+ // Parse the JSON payload
+ return JSON.parse(jsonStr) as SSEEvent;
+}
+
+/**
+ * Create a user-friendly error message from various error types.
+ *
+ * Maps technical errors to messages appropriate for end users.
+ *
+ * @param error - The original error
+ * @returns User-friendly error message
+ * @internal
+ */
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ // AbortError is expected when user cancels
+ if (error.name === 'AbortError') {
+ return 'Request was cancelled';
+ }
+
+ // Network errors
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
+ return 'Unable to connect to the server. Please check your internet connection.';
+ }
+
+ // Timeout errors
+ if (error.name === 'TimeoutError') {
+ return 'Request timed out. Please try again.';
+ }
+
+ return error.message;
+ }
+
+ return 'An unexpected error occurred';
+}
+
+/**
+ * Check if an error is a network-related error.
+ *
+ * Network errors include fetch failures, DNS issues, etc.
+ *
+ * @param error - The error to check
+ * @returns True if this is a network error
+ * @internal
+ */
+function isNetworkError(error: unknown): boolean {
+ if (error instanceof Error) {
+ // TypeError is thrown for network issues in fetch
+ if (error.name === 'TypeError') {
+ return true;
+ }
+ // Check message for common network error indicators
+ const message = error.message.toLowerCase();
+ return (
+ message.includes('network') ||
+ message.includes('fetch') ||
+ message.includes('connection')
+ );
+ }
+ return false;
+}
+
+// ============================================================================
+// Main Hook
+// ============================================================================
+
+/**
+ * useSSE Hook
+ *
+ * A comprehensive hook for managing SSE streaming connections.
+ *
+ * @remarks
+ * ## Overview
+ *
+ * This hook provides a complete solution for SSE streaming:
+ * - Initiates POST requests with fetch and processes ReadableStream responses
+ * - Parses SSE line-delimited events with proper buffering
+ * - Handles connection lifecycle and cleanup
+ * - Provides structured error handling
+ *
+ * ## Why fetch instead of EventSource?
+ *
+ * The standard EventSource API only supports GET requests. Since our
+ * query endpoint requires POST with a JSON body, we use fetch with
+ * ReadableStream to process the SSE response.
+ *
+ * ## SSE Parsing
+ *
+ * SSE events are newline-delimited with a blank line between events:
+ * ```
+ * data: {"type": "token", "content": "Hello"}\n
+ * \n
+ * data: {"type": "token", "content": " world"}\n
+ * \n
+ * ```
+ *
+ * The parser buffers incomplete lines and processes complete events
+ * as they arrive.
+ *
+ * ## Callback Refs
+ *
+ * Callbacks are stored in refs to prevent stale closure issues.
+ * This allows callers to pass inline functions without causing
+ * issues with rapidly updating state.
+ *
+ * ## Cleanup
+ *
+ * The hook automatically aborts any active stream on:
+ * - Component unmount
+ * - New stream start (previous stream aborted)
+ * - Manual abort() call
+ *
+ * @param options - Callback configuration options
+ * @returns Object with control methods and state
+ *
+ * @see {@link UseSSEOptions} for callback options
+ * @see {@link UseSSEReturn} for return type
+ */
+export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
+ const { onToken, onDone, onError, onConnectionChange } = options;
+
+ // ============================================================================
+ // State
+ // ============================================================================
+
+ /**
+ * Current connection state.
+ *
+ * Tracks the lifecycle of the SSE connection for UI feedback.
+ */
+ const [connectionState, setConnectionState] =
+ useState('disconnected');
+
+ // ============================================================================
+ // Refs
+ // ============================================================================
+
+ /**
+ * AbortController for cancelling the fetch request.
+ *
+ * Stored in a ref to persist across renders and allow
+ * abort() to be called from anywhere.
+ */
+ const abortControllerRef = useRef(null);
+
+ /**
+ * Callback refs to prevent stale closures.
+ *
+ * These refs always point to the latest callback functions,
+ * avoiding the need to recreate the startStream function
+ * when callbacks change.
+ */
+ const onTokenRef = useRef(onToken);
+ const onDoneRef = useRef(onDone);
+ const onErrorRef = useRef(onError);
+ const onConnectionChangeRef = useRef(onConnectionChange);
+
+ // Keep refs in sync with latest props
+ useEffect(() => {
+ onTokenRef.current = onToken;
+ onDoneRef.current = onDone;
+ onErrorRef.current = onError;
+ onConnectionChangeRef.current = onConnectionChange;
+ }, [onToken, onDone, onError, onConnectionChange]);
+
+ // ============================================================================
+ // Derived State
+ // ============================================================================
+
+ /**
+ * Whether currently streaming.
+ *
+ * Convenience value derived from connectionState.
+ * True during 'connecting' and 'connected' states.
+ */
+ const isStreaming =
+ connectionState === 'connecting' || connectionState === 'connected';
+
+ // ============================================================================
+ // Helper Functions
+ // ============================================================================
+
+ /**
+ * Update connection state and notify callback.
+ *
+ * Centralizes state updates to ensure callback is always fired.
+ *
+ * @param state - New connection state
+ */
+ const updateConnectionState = useCallback((state: ConnectionState) => {
+ setConnectionState(state);
+ onConnectionChangeRef.current?.(state);
+ }, []);
+
+ /**
+ * Handle an error during streaming.
+ *
+ * Updates state and notifies callback with structured error info.
+ *
+ * @param error - The error that occurred
+ * @param retryAfter - Optional retry delay from server
+ */
+ const handleError = useCallback(
+ (error: unknown, retryAfter?: number) => {
+ updateConnectionState('error');
+
+ const sseError: SSEError = {
+ message: getErrorMessage(error),
+ retryAfter,
+ isNetworkError: isNetworkError(error),
+ };
+
+ onErrorRef.current?.(sseError);
+ },
+ [updateConnectionState]
+ );
+
+ // ============================================================================
+ // Main Actions
+ // ============================================================================
+
+ /**
+ * Abort the current stream.
+ *
+ * Cancels any ongoing fetch request by signaling the AbortController.
+ * Safe to call even if no stream is active.
+ */
+ const abort = useCallback(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ updateConnectionState('disconnected');
+ }, [updateConnectionState]);
+
+ /**
+ * Start an SSE stream for a query.
+ *
+ * Performs the following:
+ * 1. Aborts any existing stream
+ * 2. Creates a new AbortController with timeout
+ * 3. Initiates POST request to query endpoint
+ * 4. Processes the ReadableStream response
+ * 5. Parses SSE events and calls appropriate callbacks
+ *
+ * @param query - The user's question
+ * @param provider - Optional preferred LLM provider
+ * @param history - Optional conversation history for multi-turn context
+ */
+ const startStream = useCallback(
+ async (query: string, provider?: string, history?: HistoryMessage[]) => {
+ // Abort any existing stream before starting new one
+ abort();
+
+ // Create new AbortController for this request
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ // Set up timeout
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, API_CONFIG.timeout);
+
+ // Update state to connecting
+ updateConnectionState('connecting');
+
+ // Buffer for incomplete SSE lines
+ let buffer = '';
+
+ try {
+ // Build request URL
+ const url = `${API_CONFIG.baseUrl}${API_CONFIG.queryEndpoint}`;
+
+ // Build request body
+ // Include stream: true to explicitly request SSE streaming
+ // Include history for multi-turn conversation context
+ const body: {
+ query: string;
+ stream: boolean;
+ provider?: string;
+ history?: HistoryMessage[];
+ } = {
+ query,
+ stream: true,
+ };
+ if (provider) {
+ body.provider = provider;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+
+ // Initiate fetch request
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream',
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+
+ // Clear timeout since we got a response
+ clearTimeout(timeoutId);
+
+ // Check for HTTP errors
+ if (!response.ok) {
+ // Try to get error message from response body
+ let errorMessage = `Server error: ${response.status}`;
+ try {
+ const errorBody = await response.json();
+ if (errorBody.message) {
+ // Standard error format
+ errorMessage = errorBody.message;
+ } else if (errorBody.detail) {
+ // FastAPI error format - detail can be string or array
+ if (typeof errorBody.detail === 'string') {
+ errorMessage = errorBody.detail;
+ } else if (Array.isArray(errorBody.detail)) {
+ // 422 Validation errors return array of error objects
+ // Extract the first error message for display
+ const firstError = errorBody.detail[0];
+ if (firstError?.msg) {
+ errorMessage = firstError.msg;
+ } else {
+ errorMessage = 'Validation error';
+ }
+ }
+ }
+ } catch {
+ // Ignore JSON parse errors, use default message
+ }
+
+ throw new Error(errorMessage);
+ }
+
+ // Ensure we have a readable body
+ if (!response.body) {
+ throw new Error('Response body is not readable');
+ }
+
+ // Get reader for the response stream
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ // Process the stream
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ // Stream ended - process any remaining buffer
+ if (buffer.trim()) {
+ try {
+ const event = parseSSELine(buffer);
+ if (event) {
+ processEvent(event);
+ }
+ } catch {
+ // Ignore parse errors for incomplete data
+ }
+ }
+ break;
+ }
+
+ // Decode the chunk and add to buffer
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete lines (delimited by \n)
+ // SSE uses \n\n to separate events, but each data: line ends with \n
+ const lines = buffer.split('\n');
+
+ // Keep the last incomplete line in buffer
+ buffer = lines.pop() ?? '';
+
+ // Process each complete line
+ for (const line of lines) {
+ // Skip empty lines (event delimiters)
+ if (!line.trim()) {
+ continue;
+ }
+
+ try {
+ const event = parseSSELine(line);
+ if (event) {
+ processEvent(event);
+ }
+ } catch (parseError) {
+ // Log parse errors but continue processing
+ console.warn('SSE parse error:', parseError, 'Line:', line);
+ }
+ }
+ }
+
+ // Stream completed normally
+ // If we're still connected (no done event received), disconnect
+ if (
+ abortControllerRef.current === controller &&
+ connectionState !== 'error'
+ ) {
+ updateConnectionState('disconnected');
+ }
+ } catch (error) {
+ // Clear timeout on error
+ clearTimeout(timeoutId);
+
+ // Don't report abort errors as failures (user cancelled)
+ if (error instanceof Error && error.name === 'AbortError') {
+ // Already handled in abort()
+ return;
+ }
+
+ handleError(error);
+ } finally {
+ // Clean up controller reference if it's still ours
+ if (abortControllerRef.current === controller) {
+ abortControllerRef.current = null;
+ }
+ }
+
+ /**
+ * Process a parsed SSE event.
+ *
+ * Routes the event to the appropriate callback based on type.
+ *
+ * @param event - Parsed SSE event
+ */
+ function processEvent(event: SSEEvent): void {
+ switch (event.type) {
+ case 'token':
+ // First token received - update state to connected
+ if (connectionState === 'connecting') {
+ updateConnectionState('connected');
+ }
+ onTokenRef.current?.(event.content);
+ break;
+
+ case 'done': {
+ // Stream completed successfully
+ const result: SSEDoneResult = {
+ response: event.response,
+ sources: event.sources.map(transformSource),
+ provider: event.provider,
+ model: event.model,
+ latencyMs: event.latency_ms,
+ };
+ updateConnectionState('disconnected');
+ onDoneRef.current?.(result);
+ break;
+ }
+
+ case 'error':
+ // Server-side error
+ handleError(new Error(event.message), event.retry_after);
+ break;
+ }
+ }
+ },
+ [abort, connectionState, handleError, updateConnectionState]
+ );
+
+ // ============================================================================
+ // Cleanup
+ // ============================================================================
+
+ /**
+ * Cleanup on unmount.
+ *
+ * Aborts any active stream when the component using this hook unmounts.
+ * This prevents memory leaks and callbacks firing on unmounted components.
+ */
+ useEffect(() => {
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ };
+ }, []);
+
+ // ============================================================================
+ // Return Value
+ // ============================================================================
+
+ return {
+ startStream,
+ abort,
+ connectionState,
+ isStreaming,
+ };
+}
diff --git a/frontend/src/styles/themes.ts b/frontend/src/styles/themes.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e3cc068b769e73aebd3665c6eec3bf7076f8cd98
--- /dev/null
+++ b/frontend/src/styles/themes.ts
@@ -0,0 +1,114 @@
+/**
+ * Theme Configuration
+ *
+ * This module defines the color palette, typography, and design tokens
+ * for the RAG Chatbot frontend. These values are used alongside
+ * Tailwind CSS for consistent theming.
+ *
+ * @module styles/themes
+ */
+
+/**
+ * Custom color palette for the application.
+ *
+ * These colors are designed to be:
+ * - Accessible (WCAG AA compliant contrast ratios)
+ * - Professional and modern
+ * - Distinct from default Tailwind colors
+ */
+export const colors = {
+ /** Primary brand color - warm orange/amber */
+ primary: {
+ 50: '#fff8f1',
+ 100: '#feecdc',
+ 200: '#fcd9bd',
+ 300: '#fdba8c',
+ 400: '#ff8a4c',
+ 500: '#ff6f26',
+ 600: '#e25216',
+ 700: '#b73f0e',
+ 800: '#933010',
+ 900: '#792811',
+ },
+ /** Secondary color - cool slate/blue */
+ secondary: {
+ 50: '#f8fafc',
+ 100: '#f1f5f9',
+ 200: '#e2e8f0',
+ 300: '#cbd5e1',
+ 400: '#94a3b8',
+ 500: '#64748b',
+ 600: '#475569',
+ 700: '#334155',
+ 800: '#1e293b',
+ 900: '#0f172a',
+ },
+ /** Success/positive states */
+ success: {
+ light: '#dcfce7',
+ DEFAULT: '#22c55e',
+ dark: '#166534',
+ },
+ /** Warning states */
+ warning: {
+ light: '#fef3c7',
+ DEFAULT: '#f59e0b',
+ dark: '#92400e',
+ },
+ /** Error/danger states */
+ error: {
+ light: '#fee2e2',
+ DEFAULT: '#ef4444',
+ dark: '#991b1b',
+ },
+} as const;
+
+/**
+ * Typography configuration.
+ */
+export const typography = {
+ /** Font family stack for body text */
+ fontFamily: {
+ sans: ['Inter', 'system-ui', 'sans-serif'],
+ mono: ['JetBrains Mono', 'Consolas', 'monospace'],
+ },
+ /** Font size scale */
+ fontSize: {
+ xs: '0.75rem',
+ sm: '0.875rem',
+ base: '1rem',
+ lg: '1.125rem',
+ xl: '1.25rem',
+ '2xl': '1.5rem',
+ '3xl': '1.875rem',
+ },
+} as const;
+
+/**
+ * Spacing and layout tokens.
+ */
+export const spacing = {
+ /** Container max widths */
+ container: {
+ chat: '768px',
+ content: '1024px',
+ },
+ /** Component-specific spacing */
+ component: {
+ messageGap: '1rem',
+ cardPadding: '1.5rem',
+ inputPadding: '0.75rem 1rem',
+ },
+} as const;
+
+/**
+ * Animation presets.
+ */
+export const animations = {
+ /** Fade in animation */
+ fadeIn: 'fadeIn 0.2s ease-out',
+ /** Slide up animation */
+ slideUp: 'slideUp 0.3s ease-out',
+ /** Pulse for loading states */
+ pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
+} as const;
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000000000000000000000000000000000000..da0c15b8f7f790781306e5d8b5e1828b8527ae7b
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,11 @@
+///
+import '@testing-library/jest-dom/vitest';
+import { vi } from 'vitest';
+
+// Mock fetch globally
+global.fetch = vi.fn();
+
+// Reset mocks between tests
+beforeEach(() => {
+ vi.resetAllMocks();
+});
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..422761b7a0618e10794bd2e835059e696dfb5533
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,129 @@
+/**
+ * Global TypeScript Type Definitions
+ *
+ * This module contains shared type definitions used throughout
+ * the RAG Chatbot frontend application.
+ *
+ * @module types
+ */
+
+/**
+ * Represents a chat message in the conversation.
+ *
+ * Messages are either from the user or the AI assistant,
+ * with optional metadata for sources and timestamps.
+ */
+export interface Message {
+ /** Unique identifier for the message */
+ id: string;
+ /** Role of the message sender */
+ role: 'user' | 'assistant';
+ /** Text content of the message */
+ content: string;
+ /** Timestamp when the message was created */
+ timestamp: Date;
+ /** Source citations for assistant responses */
+ sources?: Source[];
+ /** Whether the message is still being streamed */
+ isStreaming?: boolean;
+}
+
+/**
+ * Represents a source citation from the RAG retrieval.
+ *
+ * Sources are chunks from the pythermalcomfort documentation
+ * that were used to generate the response.
+ */
+export interface Source {
+ /** Unique identifier for the source */
+ id: string;
+ /** Heading path showing document hierarchy (e.g., "Chapter > Section > Subsection") */
+ headingPath: string;
+ /** Page number in the original document */
+ page: number;
+ /** Relevant text excerpt from the source */
+ text: string;
+ /** Relevance score from the retriever (0-1) */
+ score?: number;
+}
+
+/**
+ * LLM Provider configuration and status.
+ */
+export interface Provider {
+ /** Provider identifier */
+ id: 'gemini' | 'deepseek' | 'anthropic';
+ /** Display name for UI */
+ name: string;
+ /** Whether the provider is currently available */
+ available: boolean;
+ /** Cooldown end time if rate limited (ISO string) */
+ cooldownUntil?: string;
+}
+
+/**
+ * Represents a message in conversation history sent to the API.
+ *
+ * This is a simplified format used for the API request, containing
+ * only the role and content needed for multi-turn context.
+ */
+export interface HistoryMessage {
+ /** Role of the message sender */
+ role: 'user' | 'assistant';
+ /** Text content of the message */
+ content: string;
+}
+
+/**
+ * Query request payload sent to the API.
+ */
+export interface QueryRequest {
+ /** The user's question */
+ query: string;
+ /** Preferred LLM provider (optional, uses fallback if unavailable) */
+ provider?: Provider['id'];
+ /** Previous conversation messages for multi-turn context */
+ history?: HistoryMessage[];
+}
+
+/**
+ * Streaming response event from the SSE endpoint.
+ */
+export interface StreamEvent {
+ /** Event type */
+ type: 'token' | 'sources' | 'error' | 'done';
+ /** Token content for 'token' events */
+ token?: string;
+ /** Source citations for 'sources' events */
+ sources?: Source[];
+ /** Error message for 'error' events */
+ error?: string;
+}
+
+/**
+ * Health check response from the API.
+ */
+export interface HealthResponse {
+ /** Service status */
+ status: 'healthy' | 'degraded' | 'unhealthy';
+ /** Index version currently loaded */
+ indexVersion?: string;
+ /** Provider statuses */
+ providers?: Record;
+}
+
+/**
+ * Component props with optional className for styling.
+ */
+export interface WithClassName {
+ /** Additional CSS classes to apply */
+ className?: string;
+}
+
+/**
+ * Component props with children.
+ */
+export interface WithChildren {
+ /** React children to render */
+ children: React.ReactNode;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..cf9c65d3e0676a0169374d827f7abb97497789ef
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..890655ca574f56cf6bd3714d62e4b9e99d05ad3e
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'html'],
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'node_modules/',
+ 'src/test/',
+ '.next/',
+ '**/*.d.ts',
+ '**/*.test.{ts,tsx}',
+ '**/*.spec.{ts,tsx}',
+ ],
+ },
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, './src'),
+ },
+ },
+});
diff --git a/poetry.lock b/poetry.lock
index 1e0903ac8feeaf07aa7478cca69fdf7927aad5e2..abe71f5d5e38811609639f122dcc263f4b01310b 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -381,12 +381,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-groups = ["build", "dense", "dev", "serve"]
+groups = ["main", "build", "dense", "dev", "serve"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
-markers = {build = "platform_system == \"Windows\"", dense = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\"", serve = "platform_system == \"Windows\" or sys_platform == \"win32\""}
+markers = {main = "platform_system == \"Windows\"", build = "platform_system == \"Windows\"", dense = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\"", serve = "platform_system == \"Windows\" or sys_platform == \"win32\""}
[[package]]
name = "coloredlogs"
@@ -584,6 +584,18 @@ files = [
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
+[[package]]
+name = "distro"
+version = "1.9.0"
+description = "Distro - an OS platform information API"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"},
+ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
+]
+
[[package]]
name = "faiss-cpu"
version = "1.11.0.post1"
@@ -591,7 +603,7 @@ description = "A library for efficient similarity search and clustering of dense
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version >= \"3.12\""
+markers = "python_version >= \"3.14\""
files = [
{file = "faiss_cpu-1.11.0.post1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:e079d44ea22919f6477fea553b05854c68838ab553e1c6b1237437a8becdf89d"},
{file = "faiss_cpu-1.11.0.post1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:4ded0c91cb67f462ae00a4d339718ea2fbb23eedbf260c3a07de77c32c23205a"},
@@ -644,7 +656,7 @@ description = "A library for efficient similarity search and clustering of dense
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
-markers = "python_version == \"3.11\""
+markers = "python_version <= \"3.13\""
files = [
{file = "faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1"},
{file = "faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317"},
@@ -893,6 +905,264 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask-expr", "dask[dataframe,
test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"]
tqdm = ["tqdm"]
+[[package]]
+name = "google-ai-generativelanguage"
+version = "0.4.0"
+description = "Google Ai Generativelanguage API client library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google-ai-generativelanguage-0.4.0.tar.gz", hash = "sha256:c8199066c08f74c4e91290778329bb9f357ba1ea5d6f82de2bc0d10552bf4f8c"},
+ {file = "google_ai_generativelanguage-0.4.0-py3-none-any.whl", hash = "sha256:e4c425376c1ee26c78acbc49a24f735f90ebfa81bf1a06495fae509a2433232c"},
+]
+
+[package.dependencies]
+google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
+proto-plus = ">=1.22.3,<2.0.0dev"
+protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev"
+
+[[package]]
+name = "google-api-core"
+version = "2.25.2"
+description = "Google API client core library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+markers = "python_version >= \"3.14\""
+files = [
+ {file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"},
+ {file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"},
+]
+
+[package.dependencies]
+google-auth = ">=2.14.1,<3.0.0"
+googleapis-common-protos = ">=1.56.2,<2.0.0"
+grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
+grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
+proto-plus = {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}
+protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+requests = ">=2.18.0,<3.0.0"
+
+[package.extras]
+async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"]
+grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""]
+grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"]
+grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"]
+
+[[package]]
+name = "google-api-core"
+version = "2.29.0"
+description = "Google API client core library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+markers = "python_version <= \"3.13\""
+files = [
+ {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"},
+ {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"},
+]
+
+[package.dependencies]
+google-auth = ">=2.14.1,<3.0.0"
+googleapis-common-protos = ">=1.56.2,<2.0.0"
+grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""}
+grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
+proto-plus = [
+ {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
+ {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""},
+]
+protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+requests = ">=2.18.0,<3.0.0"
+
+[package.extras]
+async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"]
+grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""]
+grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"]
+grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"]
+
+[[package]]
+name = "google-auth"
+version = "2.47.0"
+description = "Google Authentication Library"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498"},
+ {file = "google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da"},
+]
+
+[package.dependencies]
+pyasn1-modules = ">=0.2.1"
+rsa = ">=3.1.4,<5"
+
+[package.extras]
+aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"]
+cryptography = ["cryptography (>=38.0.3)"]
+enterprise-cert = ["cryptography", "pyopenssl"]
+pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"]
+pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"]
+reauth = ["pyu2f (>=0.1.5)"]
+requests = ["requests (>=2.20.0,<3.0.0)"]
+testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"]
+urllib3 = ["packaging", "urllib3"]
+
+[[package]]
+name = "google-generativeai"
+version = "0.4.1"
+description = "Google Generative AI High level API client library and tools."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "google_generativeai-0.4.1-py3-none-any.whl", hash = "sha256:89be3c00c2e688108fccefc50f47f45fc9d37ecd53c1ade9d86b5d982919c24a"},
+]
+
+[package.dependencies]
+google-ai-generativelanguage = "0.4.0"
+google-api-core = "*"
+google-auth = ">=2.15.0"
+protobuf = "*"
+pydantic = "*"
+tqdm = "*"
+typing-extensions = "*"
+
+[package.extras]
+dev = ["Pillow", "absl-py", "black", "ipython", "nose2", "pandas", "pytype", "pyyaml"]
+
+[[package]]
+name = "googleapis-common-protos"
+version = "1.72.0"
+description = "Common protobufs used in Google APIs"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"},
+ {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"},
+]
+
+[package.dependencies]
+protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
+[package.extras]
+grpc = ["grpcio (>=1.44.0,<2.0.0)"]
+
+[[package]]
+name = "groq"
+version = "0.12.0"
+description = "The official Python library for the groq API"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "groq-0.12.0-py3-none-any.whl", hash = "sha256:e8aa1529f82a01b2d15394b7ea242af9ee9387f65bdd1b91ce9a10f5a911dac1"},
+ {file = "groq-0.12.0.tar.gz", hash = "sha256:569229e2dadfc428b0df3d2987407691a4e3bc035b5849a65ef4909514a4605e"},
+]
+
+[package.dependencies]
+anyio = ">=3.5.0,<5"
+distro = ">=1.7.0,<2"
+httpx = ">=0.23.0,<1"
+pydantic = ">=1.9.0,<3"
+sniffio = "*"
+typing-extensions = ">=4.7,<5"
+
+[[package]]
+name = "grpcio"
+version = "1.76.0"
+description = "HTTP/2-based RPC framework"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"},
+ {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"},
+ {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"},
+ {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"},
+ {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"},
+ {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"},
+ {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"},
+ {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"},
+ {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"},
+ {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"},
+ {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"},
+ {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"},
+ {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"},
+ {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"},
+ {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"},
+ {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"},
+ {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"},
+ {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"},
+ {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"},
+ {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"},
+ {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"},
+ {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"},
+ {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"},
+ {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"},
+ {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"},
+ {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"},
+ {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"},
+ {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"},
+ {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"},
+ {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"},
+ {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"},
+ {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"},
+ {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"},
+ {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"},
+ {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"},
+ {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"},
+ {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"},
+ {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"},
+ {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"},
+ {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"},
+ {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"},
+ {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"},
+ {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"},
+ {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"},
+ {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"},
+ {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"},
+ {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"},
+ {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"},
+ {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"},
+ {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"},
+ {file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"},
+ {file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"},
+ {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"},
+ {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"},
+ {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"},
+ {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"},
+ {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"},
+ {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"},
+ {file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"},
+ {file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"},
+ {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.12,<5.0"
+
+[package.extras]
+protobuf = ["grpcio-tools (>=1.76.0)"]
+
+[[package]]
+name = "grpcio-status"
+version = "1.62.3"
+description = "Status proto mapping for gRPC"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485"},
+ {file = "grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8"},
+]
+
+[package.dependencies]
+googleapis-common-protos = ">=1.5.5"
+grpcio = ">=1.62.3"
+protobuf = ">=4.21.6"
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -1661,7 +1931,7 @@ description = "Python package for creating and manipulating graphs and networks"
optional = false
python-versions = ">=3.11"
groups = ["build", "dense"]
-markers = "python_version >= \"3.12\""
+markers = "python_version >= \"3.14\""
files = [
{file = "networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f"},
{file = "networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad"},
@@ -1685,7 +1955,7 @@ description = "Python package for creating and manipulating graphs and networks"
optional = false
python-versions = "!=3.14.1,>=3.11"
groups = ["build", "dense"]
-markers = "python_version == \"3.11\""
+markers = "python_version <= \"3.13\""
files = [
{file = "networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762"},
{file = "networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509"},
@@ -1767,7 +2037,7 @@ description = "CUBLAS native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb"},
{file = "nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668"},
@@ -1781,7 +2051,7 @@ description = "CUBLAS native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0"},
{file = "nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142"},
@@ -1795,7 +2065,7 @@ description = "CUDA profiling tools runtime libs."
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:166ee35a3ff1587f2490364f90eeeb8da06cd867bd5b701bf7f9a02b78bc63fc"},
{file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.whl", hash = "sha256:358b4a1d35370353d52e12f0a7d1769fc01ff74a191689d3870b2123156184c4"},
@@ -1811,7 +2081,7 @@ description = "CUDA profiling tools runtime libs."
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed"},
{file = "nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182"},
@@ -1825,7 +2095,7 @@ description = "NVRTC native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5847f1d6e5b757f1d2b3991a01082a44aad6f10ab3c5c0213fa3e25bddc25a13"},
{file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53"},
@@ -1839,7 +2109,7 @@ description = "NVRTC native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994"},
{file = "nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8"},
@@ -1853,7 +2123,7 @@ description = "CUDA Runtime native Libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6116fad3e049e04791c0256a9778c16237837c08b27ed8c8401e2e45de8d60cd"},
{file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d461264ecb429c84c8879a7153499ddc7b19b5f8d84c204307491989a365588e"},
@@ -1869,7 +2139,7 @@ description = "CUDA Runtime native Libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d"},
{file = "nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90"},
@@ -1883,7 +2153,7 @@ description = "cuDNN runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9fd4584468533c61873e5fda8ca41bac3a38bcb2d12350830c69b0a96a7e4def"},
{file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2"},
@@ -1900,7 +2170,7 @@ description = "cuDNN runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8"},
{file = "nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8"},
@@ -1917,7 +2187,7 @@ description = "CUFFT native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d16079550df460376455cba121db6564089176d9bac9e4f360493ca4741b22a6"},
{file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8510990de9f96c803a051822618d42bf6cb8f069ff3f48d93a8486efdacb48fb"},
@@ -1936,7 +2206,7 @@ description = "CUFFT native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a"},
{file = "nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74"},
@@ -1953,7 +2223,7 @@ description = "cuFile GPUDirect libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159"},
{file = "nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8f57a0051dcf2543f6dc2b98a98cb2719c37d3cee1baba8965d57f3bbc90d4db"},
@@ -1966,7 +2236,7 @@ description = "cuFile GPUDirect libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc"},
{file = "nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:4beb6d4cce47c1a0f1013d72e02b0994730359e17801d395bdcbf20cfb3bb00a"},
@@ -1979,7 +2249,7 @@ description = "CURAND native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6e82df077060ea28e37f48a3ec442a8f47690c7499bff392a5938614b56c98d8"},
{file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf"},
@@ -1995,7 +2265,7 @@ description = "CURAND native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd"},
{file = "nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9"},
@@ -2009,7 +2279,7 @@ description = "CUDA solver native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0ce237ef60acde1efc457335a2ddadfd7610b892d94efee7b776c64bb1cac9e0"},
{file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c"},
@@ -2030,7 +2300,7 @@ description = "CUDA solver native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0"},
{file = "nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450"},
@@ -2049,7 +2319,7 @@ description = "CUSPARSE native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d25b62fb18751758fe3c93a4a08eff08effedfe4edf1c6bb5afd0890fe88f887"},
{file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7aa32fa5470cf754f72d1116c7cbc300b4e638d3ae5304cfa4a638a5b87161b1"},
@@ -2068,7 +2338,7 @@ description = "CUSPARSE native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc"},
{file = "nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b"},
@@ -2085,7 +2355,7 @@ description = "NVIDIA cuSPARSELt"
optional = false
python-versions = "*"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8371549623ba601a06322af2133c4a44350575f5a3108fb75f3ef20b822ad5f1"},
{file = "nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46"},
@@ -2099,7 +2369,7 @@ description = "NVIDIA cuSPARSELt"
optional = false
python-versions = "*"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5"},
{file = "nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623"},
@@ -2113,7 +2383,7 @@ description = "NVIDIA Collective Communication Library (NCCL) Runtime"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c196e95e832ad30fbbb50381eb3cbd1fadd5675e587a548563993609af19522"},
{file = "nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6"},
@@ -2126,7 +2396,7 @@ description = "NVIDIA Collective Communication Library (NCCL) Runtime"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31432ad4d1fb1004eb0c56203dc9bc2178a1ba69d1d9e02d64a6938ab5e40e7a"},
{file = "nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457"},
@@ -2139,7 +2409,7 @@ description = "Nvidia JIT LTO Library"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a"},
{file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf4eaa7d4b6b543ffd69d6abfb11efdeb2db48270d94dfd3a452c24150829e41"},
@@ -2153,7 +2423,7 @@ description = "Nvidia JIT LTO Library"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88"},
{file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7"},
@@ -2167,7 +2437,7 @@ description = "NVSHMEM creates a global address space that provides efficient an
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b0b960da3842212758e4fa4696b94f129090b30e5122fea3c5345916545cff0"},
{file = "nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5"},
@@ -2180,7 +2450,7 @@ description = "NVIDIA Tools Extension"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f44f8d86bb7d5629988d61c8d3ae61dddb2015dee142740536bc7481b022fe4b"},
{file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:adcaabb9d436c9761fca2b13959a2d237c5f9fd406c8e4b723c695409ff88059"},
@@ -2196,7 +2466,7 @@ description = "NVIDIA Tools Extension"
optional = false
python-versions = ">=3"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615"},
{file = "nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f"},
@@ -2322,8 +2592,8 @@ files = [
[package.dependencies]
numpy = [
- {version = ">=1.23.2", markers = "python_version == \"3.11\""},
{version = ">=1.26.0", markers = "python_version >= \"3.12\""},
+ {version = ">=1.23.2", markers = "python_version == \"3.11\""},
]
python-dateutil = ">=2.8.2"
pytz = ">=2020.1"
@@ -2665,24 +2935,43 @@ files = [
{file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"},
]
+[[package]]
+name = "proto-plus"
+version = "1.27.0"
+description = "Beautiful, Pythonic protocol buffers"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82"},
+ {file = "proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4"},
+]
+
+[package.dependencies]
+protobuf = ">=3.19.0,<7.0.0"
+
+[package.extras]
+testing = ["google-api-core (>=1.31.5)"]
+
[[package]]
name = "protobuf"
-version = "6.33.4"
+version = "4.25.8"
description = ""
optional = false
-python-versions = ">=3.9"
-groups = ["build"]
+python-versions = ">=3.8"
+groups = ["main", "build"]
files = [
- {file = "protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d"},
- {file = "protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc"},
- {file = "protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0"},
- {file = "protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e"},
- {file = "protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6"},
- {file = "protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9"},
- {file = "protobuf-6.33.4-cp39-cp39-win32.whl", hash = "sha256:955478a89559fa4568f5a81dce77260eabc5c686f9e8366219ebd30debf06aa6"},
- {file = "protobuf-6.33.4-cp39-cp39-win_amd64.whl", hash = "sha256:0f12ddbf96912690c3582f9dffb55530ef32015ad8e678cd494312bd78314c4f"},
- {file = "protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc"},
- {file = "protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91"},
+ {file = "protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0"},
+ {file = "protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9"},
+ {file = "protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f"},
+ {file = "protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7"},
+ {file = "protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0"},
+ {file = "protobuf-4.25.8-cp38-cp38-win32.whl", hash = "sha256:27d498ffd1f21fb81d987a041c32d07857d1d107909f5134ba3350e1ce80a4af"},
+ {file = "protobuf-4.25.8-cp38-cp38-win_amd64.whl", hash = "sha256:d552c53d0415449c8d17ced5c341caba0d89dbf433698e1436c8fa0aae7808a3"},
+ {file = "protobuf-4.25.8-cp39-cp39-win32.whl", hash = "sha256:077ff8badf2acf8bc474406706ad890466274191a48d0abd3bd6987107c9cde5"},
+ {file = "protobuf-4.25.8-cp39-cp39-win_amd64.whl", hash = "sha256:f4510b93a3bec6eba8fd8f1093e9d7fb0d4a24d1a81377c10c0e5bbfe9e4ed24"},
+ {file = "protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59"},
+ {file = "protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd"},
]
[[package]]
@@ -2734,6 +3023,33 @@ files = [
[package.dependencies]
numpy = ">=1.16.6,<2"
+[[package]]
+name = "pyasn1"
+version = "0.6.1"
+description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
+ {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+description = "A collection of ASN.1-based protocols modules"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
+ {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
+]
+
+[package.dependencies]
+pyasn1 = ">=0.6.1,<0.7.0"
+
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -3390,6 +3706,21 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
+[[package]]
+name = "rsa"
+version = "4.9.1"
+description = "Pure-Python RSA implementation"
+optional = false
+python-versions = "<4,>=3.6"
+groups = ["main"]
+files = [
+ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
+ {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
+]
+
+[package.dependencies]
+pyasn1 = ">=0.1.3"
+
[[package]]
name = "ruff"
version = "0.4.10"
@@ -3950,7 +4281,7 @@ description = "Tensors and Dynamic neural networks in Python with strong GPU acc
optional = false
python-versions = ">=3.9.0"
groups = ["build", "dense"]
-markers = "python_version >= \"3.12\""
+markers = "python_version >= \"3.14\""
files = [
{file = "torch-2.7.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a103b5d782af5bd119b81dbcc7ffc6fa09904c423ff8db397a1e6ea8fd71508f"},
{file = "torch-2.7.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fe955951bdf32d182ee8ead6c3186ad54781492bf03d547d31771a01b3d6fb7d"},
@@ -4013,7 +4344,7 @@ description = "Tensors and Dynamic neural networks in Python with strong GPU acc
optional = false
python-versions = ">=3.10"
groups = ["build", "dense"]
-markers = "python_version == \"3.11\""
+markers = "python_version <= \"3.13\""
files = [
{file = "torch-2.9.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1cc208435f6c379f9b8fdfd5ceb5be1e3b72a6bdf1cb46c0d2812aa73472db9e"},
{file = "torch-2.9.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:9fd35c68b3679378c11f5eb73220fdcb4e6f4592295277fbb657d31fd053237c"},
@@ -4065,6 +4396,7 @@ nvidia-nccl-cu12 = {version = "2.27.5", markers = "platform_system == \"Linux\"
nvidia-nvjitlink-cu12 = {version = "12.8.93", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
nvidia-nvshmem-cu12 = {version = "3.3.20", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
nvidia-nvtx-cu12 = {version = "12.8.90", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
+setuptools = {version = "*", markers = "python_version >= \"3.12\""}
sympy = ">=1.13.3"
triton = {version = "3.5.1", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
typing-extensions = ">=4.10.0"
@@ -4080,7 +4412,7 @@ version = "4.67.1"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
-groups = ["build", "dense"]
+groups = ["main", "build", "dense"]
files = [
{file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"},
{file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"},
@@ -4172,7 +4504,7 @@ description = "A language and compiler for custom Deep Learning operations"
optional = false
python-versions = "*"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.12\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version >= \"3.14\""
files = [
{file = "triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e"},
{file = "triton-3.3.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b31e3aa26f8cb3cc5bf4e187bf737cbacf17311e1112b781d4a059353dfd731b"},
@@ -4197,7 +4529,7 @@ description = "A language and compiler for custom Deep Learning operations"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["build", "dense"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.11\""
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.13\""
files = [
{file = "triton-3.5.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f63e34dcb32d7bd3a1d0195f60f30d2aee8b08a69a0424189b71017e23dfc3d2"},
{file = "triton-3.5.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5fc53d849f879911ea13f4a877243afc513187bc7ee92d1f2c0f1ba3169e3c94"},
@@ -4892,4 +5224,4 @@ propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
python-versions = "^3.11"
-content-hash = "94c4f0ed9b8e4374af3ffb7c7f80c7fae2efdcb4986acc259032203712d7c9d7"
+content-hash = "e1f6cd7d318d4145daf675ab6b76f31203b8b41bc9d7099f2df1d9da7ad8ff4e"
diff --git a/pyproject.toml b/pyproject.toml
index 201edc092dd8dafb9c7a00cf606501d78abd2bea..363b9db266d97f92c0b6839fb4c6fe613a8d3ed7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,7 +21,9 @@ version = "0.1.0"
description = "RAG chatbot for pythermalcomfort library documentation"
# Path to the README file for long description on PyPI
-readme = "README.md"
+# PROJECT_README.md contains developer documentation
+# README.md is reserved for HuggingFace Space configuration
+readme = "PROJECT_README.md"
# Minimum Python version required (Python 3.11+)
# We require 3.11+ for improved performance, better error messages,
@@ -63,7 +65,7 @@ name = "rag-chatbot"
version = "0.1.0"
description = "RAG chatbot for pythermalcomfort library documentation"
authors = ["sadickam"]
-readme = "README.md"
+readme = "PROJECT_README.md"
# Package configuration for src layout
# This tells Poetry where to find the package code.
@@ -137,6 +139,17 @@ rank-bm25 = "^0.2"
# See: https://github.com/facebookresearch/faiss
faiss-cpu = "^1.8"
+# Google Generative AI SDK for Gemini API integration
+# Primary LLM provider with streaming support and async capabilities
+# See: https://ai.google.dev/
+google-generativeai = "^0.4"
+
+# Groq SDK for fast LPU-based inference
+# Alternative LLM provider with extremely fast inference speeds
+# Supports models: openai/gpt-oss-120b, llama-3.3-70b-versatile
+# See: https://console.groq.com/docs
+groq = "^0.12"
+
# -----------------------------------------------------------------------------
# Serve Dependencies (API Server)
# -----------------------------------------------------------------------------
@@ -427,6 +440,7 @@ disallow_untyped_defs = false
# Third-party libraries without type stubs
# These libraries don't provide type information, so we ignore missing imports
+# and follow imports anyway (for libraries that are installed but untyped)
[[tool.mypy.overrides]]
module = [
"fitz",
@@ -447,8 +461,14 @@ module = [
"rich.progress",
"rich.table",
"wordsegment",
+ "google",
+ "google.generativeai",
+ "google.generativeai.*",
+ "google.api_core",
+ "google.api_core.*",
]
ignore_missing_imports = true
+follow_untyped_imports = true
# -----------------------------------------------------------------------------
# Ruff Configuration
@@ -533,6 +553,8 @@ ignore = ["D100", "D104", "D203", "D213", "ANN101", "ANN102"]
"ERA001", # Commented-out code may be useful for test documentation
"ARG001", # Unused function args common in test callbacks
"ARG002", # Unused method args common for fixtures that set up mocks
+ "ANN401", # Any type is acceptable in test fixtures
+ "TRY003", # Exception messages in tests can be descriptive strings
]
# -----------------------------------------------------------------------------
diff --git a/scripts/test_gemini_live.py b/scripts/test_gemini_live.py
new file mode 100644
index 0000000000000000000000000000000000000000..83cd58cca42a1ebf8e887cff34e4df27790583ae
--- /dev/null
+++ b/scripts/test_gemini_live.py
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+"""Live integration test for Gemini LLM provider.
+
+This script tests the GeminiLLM implementation against the real Gemini API.
+It validates all functionality including:
+ - Provider availability check
+ - Health check connectivity
+ - Complete response generation
+ - Streaming response generation
+
+Usage:
+ # Option 1: Add GEMINI_API_KEY to .env file
+ echo 'GEMINI_API_KEY="your-api-key"' >> .env
+
+ # Option 2: Set in environment
+ export GEMINI_API_KEY="your-api-key"
+
+ # Run the test
+ poetry run python scripts/test_gemini_live.py
+
+Requirements:
+ - Valid GEMINI_API_KEY in .env file or environment variable
+ - google-generativeai package installed
+
+Note:
+ This script makes real API calls and may consume API quota.
+
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ pass
+
+
+def load_env() -> None:
+ """Load environment variables from .env file."""
+ # Try to load from .env file in project root
+ env_path = Path(__file__).parent.parent / ".env"
+ if env_path.exists():
+ try:
+ from dotenv import load_dotenv
+ load_dotenv(env_path)
+ print(f"[OK] Loaded environment from {env_path}")
+ except ImportError:
+ # dotenv not installed, try manual parsing
+ print("[INFO] python-dotenv not installed, parsing .env manually")
+ with open(env_path) as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key and value:
+ os.environ[key] = value
+ print(f"[OK] Manually loaded environment from {env_path}")
+ else:
+ print(f"[INFO] No .env file found at {env_path}")
+
+
+async def run_live_test() -> bool:
+ """Run live integration tests against Gemini API.
+
+ Returns:
+ True if all tests pass, False otherwise.
+
+ """
+ print("=" * 60)
+ print("GEMINI LLM LIVE INTEGRATION TEST")
+ print("=" * 60)
+ print()
+
+ # Check for API key
+ api_key = os.environ.get("GEMINI_API_KEY")
+ if not api_key:
+ print("[FAIL] GEMINI_API_KEY environment variable not set")
+ print()
+ print("Please set your API key:")
+ print(" export GEMINI_API_KEY='your-api-key'")
+ return False
+
+ print(f"[OK] API key found (length: {len(api_key)} chars)")
+ print()
+
+ # Import the provider (lazy loading test)
+ print("-" * 60)
+ print("TEST 1: Import and Initialization")
+ print("-" * 60)
+
+ try:
+ from rag_chatbot.llm.gemini import GeminiLLM, RateLimitError
+ from rag_chatbot.llm.base import LLMRequest
+
+ print("[OK] Imports successful (lazy loading verified)")
+ except ImportError as e:
+ print(f"[FAIL] Import error: {e}")
+ return False
+
+ # Initialize provider with gemma-3-27b-it model
+ try:
+ llm = GeminiLLM(api_key=api_key, model="gemma-3-27b-it")
+ print(f"[OK] GeminiLLM initialized")
+ print(f" Provider: {llm.provider_name}")
+ print(f" Model: {llm.model_name}")
+ print(f" Timeout: {llm.timeout_ms}ms")
+ except Exception as e:
+ print(f"[FAIL] Initialization error: {e}")
+ return False
+
+ print()
+
+ # Test is_available
+ print("-" * 60)
+ print("TEST 2: is_available Property")
+ print("-" * 60)
+
+ if llm.is_available:
+ print("[OK] Provider reports available")
+ else:
+ print("[FAIL] Provider reports unavailable")
+ return False
+
+ print()
+
+ # Test check_health
+ print("-" * 60)
+ print("TEST 3: check_health() Method")
+ print("-" * 60)
+ print("Making lightweight API call to verify connectivity...")
+
+ try:
+ is_healthy = await llm.check_health()
+ if is_healthy:
+ print("[OK] Health check passed - API is reachable")
+ else:
+ print("[WARN] Health check returned False")
+ print(" This may indicate API issues or invalid key")
+ except Exception as e:
+ print(f"[FAIL] Health check error: {e}")
+ return False
+
+ print()
+
+ # Test generate
+ print("-" * 60)
+ print("TEST 4: generate() Method")
+ print("-" * 60)
+
+ request = LLMRequest(
+ query="What is thermal comfort in buildings? Answer in 2-3 sentences.",
+ context=[
+ "Thermal comfort is the condition of mind that expresses satisfaction with the thermal environment.",
+ "ASHRAE Standard 55 defines thermal comfort conditions for building occupants.",
+ ],
+ max_tokens=256,
+ temperature=0.7,
+ )
+
+ print(f"Query: {request.query}")
+ print(f"Context chunks: {len(request.context)}")
+ print(f"Max tokens: {request.max_tokens}")
+ print(f"Temperature: {request.temperature}")
+ print()
+ print("Generating response...")
+
+ try:
+ response = await llm.generate(request)
+ print()
+ print("[OK] Response received!")
+ print(f" Provider: {response.provider}")
+ print(f" Model: {response.model}")
+ print(f" Tokens used: {response.tokens_used}")
+ print(f" Latency: {response.latency_ms}ms")
+ print()
+ print("Response content:")
+ print("-" * 40)
+ print(response.content)
+ print("-" * 40)
+ except RateLimitError as e:
+ print(f"[WARN] Rate limit hit: {e}")
+ print(f" Retry after: {e.retry_after} seconds")
+ except TimeoutError as e:
+ print(f"[FAIL] Timeout: {e}")
+ return False
+ except RuntimeError as e:
+ print(f"[FAIL] API error: {e}")
+ return False
+ except Exception as e:
+ print(f"[FAIL] Unexpected error: {type(e).__name__}: {e}")
+ return False
+
+ print()
+
+ # Test stream
+ print("-" * 60)
+ print("TEST 5: stream() Method")
+ print("-" * 60)
+
+ stream_request = LLMRequest(
+ query="What is PMV? Answer in one sentence.",
+ context=["PMV stands for Predicted Mean Vote, a thermal comfort index."],
+ max_tokens=128,
+ temperature=0.5,
+ )
+
+ print(f"Query: {stream_request.query}")
+ print()
+ print("Streaming response:")
+ print("-" * 40)
+
+ try:
+ chunks_received = 0
+ full_response = ""
+
+ async for chunk in llm.stream(stream_request):
+ print(chunk, end="", flush=True)
+ full_response += chunk
+ chunks_received += 1
+
+ print()
+ print("-" * 40)
+ print()
+ print(f"[OK] Streaming complete!")
+ print(f" Chunks received: {chunks_received}")
+ print(f" Total length: {len(full_response)} chars")
+ except RateLimitError as e:
+ print()
+ print(f"[WARN] Rate limit hit: {e}")
+ print(f" Retry after: {e.retry_after} seconds")
+ except TimeoutError as e:
+ print()
+ print(f"[FAIL] Timeout: {e}")
+ return False
+ except RuntimeError as e:
+ print()
+ print(f"[FAIL] API error: {e}")
+ return False
+ except Exception as e:
+ print()
+ print(f"[FAIL] Unexpected error: {type(e).__name__}: {e}")
+ return False
+
+ print()
+
+ # Summary
+ print("=" * 60)
+ print("TEST SUMMARY")
+ print("=" * 60)
+ print()
+ print("[OK] All live integration tests PASSED!")
+ print()
+ print("The GeminiLLM provider is working correctly with:")
+ print(" - Valid API key authentication")
+ print(" - Health check connectivity")
+ print(" - Complete response generation")
+ print(" - Streaming response generation")
+ print()
+
+ return True
+
+
+def main() -> int:
+ """Main entry point."""
+ # Load environment variables from .env file
+ load_env()
+ print()
+
+ try:
+ success = asyncio.run(run_live_test())
+ return 0 if success else 1
+ except KeyboardInterrupt:
+ print("\n\nTest interrupted by user")
+ return 130
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/test_groq_live.py b/scripts/test_groq_live.py
new file mode 100644
index 0000000000000000000000000000000000000000..e31840d8e0a17e5411729236b1bc7f67866945d9
--- /dev/null
+++ b/scripts/test_groq_live.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""Live integration test for Groq LLM provider.
+
+This script tests the Groq API with a real API key to verify
+the implementation works correctly.
+
+Usage:
+ source therm_venv/bin/activate
+ python scripts/test_groq_live.py
+
+Requires:
+ - GROQ_API_KEY environment variable set
+ - groq package installed
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+# Add src to path for imports
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
+
+
+async def test_groq_llm() -> bool:
+ """Test GroqLLM provider with a real API call.
+
+ Returns:
+ True if all tests pass, False otherwise.
+ """
+ from rag_chatbot.llm.groq import GroqLLM
+ from rag_chatbot.llm.base import LLMRequest
+
+ # Get API key from environment
+ api_key = os.environ.get("GROQ_API_KEY")
+ if not api_key:
+ print("ERROR: GROQ_API_KEY environment variable not set")
+ return False
+
+ print("=" * 60)
+ print("Groq LLM Live Integration Test")
+ print("=" * 60)
+
+ # Test 1: Initialize GroqLLM
+ print("\n[1] Testing GroqLLM initialization...")
+ try:
+ llm = GroqLLM(api_key=api_key)
+ print(f" Provider: {llm.provider_name}")
+ print(f" Model: {llm.model_name}")
+ print(f" Available: {llm.is_available}")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 2: Check health
+ print("\n[2] Testing check_health()...")
+ try:
+ is_healthy = await llm.check_health()
+ print(f" Healthy: {is_healthy}")
+ if is_healthy:
+ print(" PASSED")
+ else:
+ print(" FAILED: API returned unhealthy")
+ return False
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 3: Generate response
+ print("\n[3] Testing generate()...")
+ try:
+ request = LLMRequest(
+ query="What is PMV in thermal comfort?",
+ context=["PMV stands for Predicted Mean Vote, a thermal comfort index."],
+ max_tokens=100,
+ temperature=0.7,
+ )
+ response = await llm.generate(request)
+ print(f" Provider: {response.provider}")
+ print(f" Model: {response.model}")
+ print(f" Tokens: {response.tokens_used}")
+ print(f" Latency: {response.latency_ms}ms")
+ print(f" Content: {response.content[:100]}...")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 4: Stream response
+ print("\n[4] Testing stream()...")
+ try:
+ request = LLMRequest(
+ query="What is thermal comfort?",
+ context=[],
+ max_tokens=50,
+ temperature=0.7,
+ )
+ chunks: list[str] = []
+ async for chunk in llm.stream(request):
+ chunks.append(chunk)
+ full_response = "".join(chunks)
+ print(f" Chunks received: {len(chunks)}")
+ print(f" Total length: {len(full_response)}")
+ print(f" Content: {full_response[:100]}...")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 5: Test with secondary model
+ print("\n[5] Testing secondary model (llama-3.3-70b-versatile)...")
+ try:
+ llm2 = GroqLLM(api_key=api_key, model="llama-3.3-70b-versatile")
+ request = LLMRequest(
+ query="Say hello",
+ context=[],
+ max_tokens=20,
+ temperature=0.7,
+ )
+ response = await llm2.generate(request)
+ print(f" Model: {response.model}")
+ print(f" Content: {response.content[:50]}...")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ print("\n" + "=" * 60)
+ print("All tests PASSED!")
+ print("=" * 60)
+ return True
+
+
+async def test_groq_registry() -> bool:
+ """Test GroqProviderRegistry with model fallback.
+
+ Returns:
+ True if all tests pass, False otherwise.
+ """
+ from rag_chatbot.llm.groq_registry import GroqProviderRegistry
+ from rag_chatbot.llm.base import LLMRequest
+
+ api_key = os.environ.get("GROQ_API_KEY")
+ if not api_key:
+ print("ERROR: GROQ_API_KEY environment variable not set")
+ return False
+
+ print("\n" + "=" * 60)
+ print("Groq Provider Registry Live Integration Test")
+ print("=" * 60)
+
+ # Test 1: Initialize registry
+ print("\n[1] Testing GroqProviderRegistry initialization...")
+ try:
+ registry = GroqProviderRegistry(api_key=api_key)
+ print(f" Available: {registry.is_available}")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 2: Get quota status
+ print("\n[2] Testing get_quota_status()...")
+ try:
+ statuses = registry.get_quota_status()
+ for status in statuses:
+ print(f" {status.model}:")
+ print(f" Available: {status.is_available}")
+ print(f" RPM remaining: {status.requests_remaining_minute}")
+ print(f" TPD remaining: {status.tokens_remaining_day}")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 3: Generate with auto-selection
+ print("\n[3] Testing generate() with auto model selection...")
+ try:
+ request = LLMRequest(
+ query="What is PMV?",
+ context=["PMV is a thermal comfort index."],
+ max_tokens=50,
+ temperature=0.7,
+ )
+ response = await registry.generate(request)
+ print(f" Model used: {response.model}")
+ print(f" Content: {response.content[:80]}...")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ # Test 4: Generate with preferred model
+ print("\n[4] Testing generate() with preferred model...")
+ try:
+ request = LLMRequest(
+ query="Hello",
+ context=[],
+ max_tokens=20,
+ temperature=0.7,
+ )
+ response = await registry.generate(
+ request, preferred_model="llama-3.3-70b-versatile"
+ )
+ print(f" Model used: {response.model}")
+ print(f" Content: {response.content[:50]}...")
+ print(" PASSED")
+ except Exception as e:
+ print(f" FAILED: {e}")
+ return False
+
+ print("\n" + "=" * 60)
+ print("All registry tests PASSED!")
+ print("=" * 60)
+ return True
+
+
+async def main() -> int:
+ """Run all live integration tests."""
+ # Load .env file if it exists
+ env_file = os.path.join(os.path.dirname(__file__), "..", ".env")
+ if os.path.exists(env_file):
+ with open(env_file) as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, value = line.split("=", 1)
+ os.environ.setdefault(key.strip(), value.strip())
+
+ # Run tests
+ llm_passed = await test_groq_llm()
+ registry_passed = await test_groq_registry()
+
+ if llm_passed and registry_passed:
+ print("\n\nALL INTEGRATION TESTS PASSED!")
+ return 0
+ else:
+ print("\n\nSOME TESTS FAILED!")
+ return 1
+
+
+if __name__ == "__main__":
+ exit_code = asyncio.run(main())
+ sys.exit(exit_code)
diff --git a/src/rag_chatbot/__init__.py b/src/rag_chatbot/__init__.py
index 86a5652c7d7ad06235f70ed433bcd79a3b106e4c..4df562f2339b0f140cc64c58cc214ca61d5fb2c7 100644
--- a/src/rag_chatbot/__init__.py
+++ b/src/rag_chatbot/__init__.py
@@ -89,7 +89,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from rag_chatbot.api import create_app
from rag_chatbot.chunking import Chunker, ChunkingStrategy
- from rag_chatbot.chunking.chunker import Chunk
+ from rag_chatbot.chunking.models import Chunk
from rag_chatbot.config import Settings
from rag_chatbot.embeddings import BGEEncoder, EmbeddingStorage
from rag_chatbot.extraction import (
@@ -110,7 +110,7 @@ if TYPE_CHECKING:
from rag_chatbot.llm.quota import ProviderStatus
from rag_chatbot.qlog import HFDatasetWriter, QueryLog
from rag_chatbot.retrieval import BM25Retriever, FAISSIndex, HybridRetriever
- from rag_chatbot.retrieval.hybrid import RetrievalResult
+ from rag_chatbot.retrieval.models import RetrievalResult
# =============================================================================
# Package Metadata
@@ -193,7 +193,7 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"MarkdownConverter": ("rag_chatbot.extraction", "MarkdownConverter"),
# chunking
"Chunker": ("rag_chatbot.chunking", "Chunker"),
- "Chunk": ("rag_chatbot.chunking.chunker", "Chunk"),
+ "Chunk": ("rag_chatbot.chunking.models", "Chunk"),
"ChunkingStrategy": ("rag_chatbot.chunking", "ChunkingStrategy"),
# embeddings
"BGEEncoder": ("rag_chatbot.embeddings", "BGEEncoder"),
@@ -202,7 +202,7 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"FAISSIndex": ("rag_chatbot.retrieval", "FAISSIndex"),
"BM25Retriever": ("rag_chatbot.retrieval", "BM25Retriever"),
"HybridRetriever": ("rag_chatbot.retrieval", "HybridRetriever"),
- "RetrievalResult": ("rag_chatbot.retrieval.hybrid", "RetrievalResult"),
+ "RetrievalResult": ("rag_chatbot.retrieval.models", "RetrievalResult"),
# llm
"BaseLLM": ("rag_chatbot.llm", "BaseLLM"),
"GeminiLLM": ("rag_chatbot.llm", "GeminiLLM"),
diff --git a/src/rag_chatbot/api/__init__.py b/src/rag_chatbot/api/__init__.py
index 335db1511376b68998c93ea878a9fd9170d8d1f3..84ea17e45ac8e4277a4fee3aa3345b773dd719c6 100644
--- a/src/rag_chatbot/api/__init__.py
+++ b/src/rag_chatbot/api/__init__.py
@@ -10,6 +10,7 @@ Components:
- create_app: Factory function for FastAPI application
- query router: Handles chat queries
- health router: Provides health checks
+ - SSE utilities: Server-Sent Events streaming support
Lazy Loading:
FastAPI and related dependencies are loaded on first access
@@ -22,6 +23,15 @@ Example:
>>> app = create_app()
>>> # Run with: uvicorn rag_chatbot.api:app
+SSE Streaming Example:
+-------
+ >>> from rag_chatbot.api import stream_sse_response, SourceInfo
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Stream LLM response as SSE events
+ >>> async for event in stream_sse_response(registry, request, sources):
+ ... yield event
+
"""
from __future__ import annotations
@@ -29,12 +39,33 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
+ from .artifact_downloader import ArtifactDownloader, ArtifactDownloadError
from .main import create_app
+ from .sse import (
+ DoneEvent,
+ ErrorEvent,
+ SourceInfo,
+ TokenEvent,
+ format_sse_event,
+ stream_sse_response,
+ )
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["create_app"]
+__all__: list[str] = [
+ "create_app",
+ # SSE utilities
+ "TokenEvent",
+ "DoneEvent",
+ "ErrorEvent",
+ "SourceInfo",
+ "stream_sse_response",
+ "format_sse_event",
+ # Artifact downloader
+ "ArtifactDownloader",
+ "ArtifactDownloadError",
+]
def __getattr__(name: str) -> object:
@@ -56,9 +87,37 @@ def __getattr__(name: str) -> object:
AttributeError: If the attribute is not a valid export.
"""
+ # Main app factory
if name == "create_app":
from .main import create_app
return create_app
+
+ # SSE utilities - use a mapping to reduce return statements
+ sse_exports = {
+ "TokenEvent",
+ "DoneEvent",
+ "ErrorEvent",
+ "SourceInfo",
+ "stream_sse_response",
+ "format_sse_event",
+ }
+
+ if name in sse_exports:
+ from . import sse
+
+ return getattr(sse, name)
+
+ # Artifact downloader exports
+ artifact_exports = {
+ "ArtifactDownloader",
+ "ArtifactDownloadError",
+ }
+
+ if name in artifact_exports:
+ from . import artifact_downloader
+
+ return getattr(artifact_downloader, name)
+
msg = f"module {__name__!r} has no attribute {name!r}" # pragma: no cover
raise AttributeError(msg) # pragma: no cover
diff --git a/src/rag_chatbot/api/artifact_downloader.py b/src/rag_chatbot/api/artifact_downloader.py
new file mode 100644
index 0000000000000000000000000000000000000000..a24b97c044c63159467f9a045498b88205fd198d
--- /dev/null
+++ b/src/rag_chatbot/api/artifact_downloader.py
@@ -0,0 +1,657 @@
+"""Artifact downloader and cache manager for RAG pipeline.
+
+This module provides functionality to download and cache RAG pipeline
+artifacts from a HuggingFace dataset repository. The downloader handles:
+ - Version-based cache invalidation
+ - Retry logic with exponential backoff for failed downloads
+ - Force refresh via environment variable
+ - Local caching for fast startup on subsequent runs
+
+Artifacts Downloaded:
+ - chunks.parquet: Document chunks with metadata for retrieval
+ - embeddings.parquet: Embedding vectors for semantic search
+ - faiss_index.bin: FAISS index for dense retrieval
+ - bm25_index.pkl: BM25 index for sparse/lexical retrieval
+ - index_version.txt: Version identifier for cache invalidation
+
+Design Philosophy:
+ The downloader is designed for server startup scenarios. On first
+ startup, it downloads all artifacts from HuggingFace. On subsequent
+ startups, it compares the remote index_version.txt with the cached
+ version - if they match, cached artifacts are reused (fast startup).
+ If they differ, all artifacts are re-downloaded to ensure consistency.
+
+Cache Invalidation:
+ Cache invalidation is version-based:
+ 1. On startup, fetch remote index_version.txt from HF
+ 2. Compare with cached index_version.txt (if exists)
+ 3. If versions match AND FORCE_ARTIFACT_REFRESH=false -> use cache
+ 4. If versions mismatch OR FORCE_ARTIFACT_REFRESH=true -> re-download all
+
+Lazy Loading:
+ huggingface_hub is imported inside methods (not at module level) to
+ avoid import overhead. This follows the project convention used
+ throughout the codebase for heavy dependencies.
+
+Example:
+-------
+ >>> from rag_chatbot.api.artifact_downloader import ArtifactDownloader
+ >>> from rag_chatbot.config.settings import Settings
+ >>>
+ >>> settings = Settings()
+ >>> downloader = ArtifactDownloader(settings)
+ >>> cache_path = await downloader.ensure_artifacts_available()
+ >>> # cache_path now points to directory with all artifacts
+
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from functools import partial
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+if TYPE_CHECKING:
+ from rag_chatbot.config.settings import Settings
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = ["ArtifactDownloader", "ArtifactDownloadError"]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Artifact filenames (same as defined in publisher module)
+CHUNKS_PARQUET: Final[str] = "chunks.parquet"
+"""Filename for chunks parquet file containing document chunks."""
+
+EMBEDDINGS_PARQUET: Final[str] = "embeddings.parquet"
+"""Filename for embeddings parquet file containing embedding vectors."""
+
+FAISS_INDEX_BIN: Final[str] = "faiss_index.bin"
+"""Filename for serialized FAISS index binary."""
+
+BM25_INDEX_PKL: Final[str] = "bm25_index.pkl"
+"""Filename for serialized BM25 index pickle file."""
+
+INDEX_VERSION_TXT: Final[str] = "index_version.txt"
+"""Filename for index version identifier used for cache invalidation."""
+
+SOURCE_MANIFEST_JSON: Final[str] = "source_manifest.json"
+"""Filename for source manifest JSON with file hashes and metadata."""
+
+# All artifact files that need to be downloaded
+ARTIFACT_FILES: Final[tuple[str, ...]] = (
+ CHUNKS_PARQUET,
+ EMBEDDINGS_PARQUET,
+ FAISS_INDEX_BIN,
+ BM25_INDEX_PKL,
+ INDEX_VERSION_TXT,
+ SOURCE_MANIFEST_JSON,
+)
+"""Tuple of all artifact filenames to download from HuggingFace."""
+
+# Retry configuration
+MAX_RETRIES: Final[int] = 3
+"""Maximum number of retry attempts for failed downloads."""
+
+BASE_BACKOFF_SECONDS: Final[float] = 1.0
+"""Base delay in seconds for exponential backoff between retries."""
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
+
+
+class ArtifactDownloadError(Exception):
+ """Exception raised when artifact download fails.
+
+ This exception is raised when the downloader fails to download one or
+ more artifacts from HuggingFace, even after exhausting all retry attempts.
+
+ Attributes:
+ ----------
+ message : str
+ Human-readable description of the error.
+ filename : str | None
+ Name of the artifact file that failed to download (if applicable).
+ cause : Exception | None
+ The underlying exception that caused the failure.
+
+ Example:
+ -------
+ >>> try:
+ ... await downloader.ensure_artifacts_available()
+ ... except ArtifactDownloadError as e:
+ ... print(f"Failed to download: {e.filename}")
+ ... print(f"Cause: {e.cause}")
+
+ """
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ filename: str | None = None,
+ cause: Exception | None = None,
+ ) -> None:
+ """Initialize an ArtifactDownloadError.
+
+ Args:
+ ----
+ message: Human-readable description of the error.
+ filename: Name of the artifact file that failed (optional).
+ cause: The underlying exception that caused the failure (optional).
+
+ """
+ super().__init__(message)
+ self.message = message
+ self.filename = filename
+ self.cause = cause
+
+ def __str__(self) -> str:
+ """Return a string representation of the error.
+
+ Returns
+ -------
+ Formatted error message with filename and cause if available.
+
+ """
+ parts = [self.message]
+ if self.filename:
+ parts.append(f"(file: {self.filename})")
+ if self.cause:
+ parts.append(f"[cause: {self.cause!s}]")
+ return " ".join(parts)
+
+
+# =============================================================================
+# ArtifactDownloader Class
+# =============================================================================
+
+
+class ArtifactDownloader:
+ """Downloads and caches RAG artifacts from HuggingFace.
+
+ This class manages the download and caching of RAG pipeline artifacts
+ from a HuggingFace dataset repository. It provides:
+ - Automatic caching on first download
+ - Version-based cache invalidation
+ - Retry logic with exponential backoff
+ - Force refresh capability via environment variable
+
+ The downloader ensures all artifacts are available before the RAG
+ pipeline can start serving queries. On first startup, all artifacts
+ are downloaded. On subsequent startups, the version is checked and
+ artifacts are only re-downloaded if the version has changed.
+
+ Attributes:
+ ----------
+ settings : Settings
+ Application settings containing HF repo ID, token, cache path, etc.
+
+ Example:
+ -------
+ >>> from rag_chatbot.api.artifact_downloader import ArtifactDownloader
+ >>> from rag_chatbot.config.settings import Settings
+ >>>
+ >>> settings = Settings()
+ >>> downloader = ArtifactDownloader(settings)
+ >>> cache_path = await downloader.ensure_artifacts_available()
+ >>> print(f"Artifacts available at: {cache_path}")
+
+ Note:
+ ----
+ huggingface_hub is lazily imported inside methods to avoid import
+ overhead at module load time. This follows project conventions.
+
+ """
+
+ def __init__(self, settings: Settings | None = None) -> None:
+ """Initialize the artifact downloader.
+
+ Creates a new ArtifactDownloader instance with the given settings.
+ If no settings are provided, a new Settings instance is created
+ from environment variables.
+
+ Args:
+ ----
+ settings: Application settings. If None, Settings() is created.
+
+ Example:
+ -------
+ >>> downloader = ArtifactDownloader() # Uses default settings
+ >>> downloader = ArtifactDownloader(Settings()) # Explicit settings
+
+ """
+ # Lazy load Settings if not provided
+ if settings is None:
+ from rag_chatbot.config.settings import Settings
+
+ settings = Settings()
+
+ self._settings = settings
+ self._cache_path = Path(settings.artifact_cache_path)
+
+ logger.debug(
+ "Initialized ArtifactDownloader: repo=%s, cache=%s, force_refresh=%s",
+ settings.hf_index_repo,
+ self._cache_path,
+ settings.force_artifact_refresh,
+ )
+
+ @property
+ def settings(self) -> Settings:
+ """Get the downloader settings.
+
+ Returns
+ -------
+ The Settings instance used by this downloader.
+
+ """
+ return self._settings
+
+ @property
+ def cache_path(self) -> Path:
+ """Get the local cache directory path.
+
+ Returns
+ -------
+ Path to the local directory where artifacts are cached.
+
+ """
+ return self._cache_path
+
+ async def ensure_artifacts_available(self) -> Path:
+ """Ensure all artifacts are downloaded and return cache path.
+
+ This is the main entry point for the downloader. It checks whether
+ cached artifacts are valid (version match) and downloads/refreshes
+ them if necessary. Returns the path to the cache directory containing
+ all artifacts.
+
+ The method performs these steps:
+ 1. Create cache directory if it doesn't exist
+ 2. Check if force refresh is enabled
+ 3. If not forced, check version match between local and remote
+ 4. If versions match, use cached artifacts (fast path)
+ 5. If versions mismatch or forced, download all artifacts
+
+ Returns:
+ -------
+ Path to the cache directory containing all artifact files.
+
+ Raises:
+ ------
+ ArtifactDownloadError: If download fails after all retry attempts.
+
+ Example:
+ -------
+ >>> cache_path = await downloader.ensure_artifacts_available()
+ >>> faiss_path = cache_path / "faiss_index.bin"
+ >>> assert faiss_path.exists()
+
+ """
+ start_time = time.perf_counter()
+
+ # Ensure cache directory exists
+ self._cache_path.mkdir(parents=True, exist_ok=True)
+ logger.info("Cache directory: %s", self._cache_path)
+
+ # Check if force refresh is enabled
+ if self._settings.force_artifact_refresh:
+ logger.info(
+ "FORCE_ARTIFACT_REFRESH=true, bypassing cache and re-downloading"
+ )
+ await self._download_all_artifacts()
+ elif self._check_version_match():
+ # Cache hit - versions match, use cached artifacts
+ logger.info("Cache HIT: versions match, using cached artifacts")
+ self._log_cached_artifact_sizes()
+ else:
+ # Cache miss - versions don't match or no local version
+ logger.info("Cache MISS: downloading all artifacts from HuggingFace")
+ await self._download_all_artifacts()
+
+ elapsed = time.perf_counter() - start_time
+ logger.info(
+ "Artifacts ready in %.2f seconds at: %s",
+ elapsed,
+ self._cache_path,
+ )
+
+ return self._cache_path
+
+ def _check_version_match(self) -> bool:
+ """Compare local and remote index versions.
+
+ Checks whether the locally cached index_version.txt matches the
+ remote version on HuggingFace. If they match, cached artifacts
+ are valid and can be reused.
+
+ Returns:
+ -------
+ True if versions match (cache hit), False otherwise (cache miss).
+
+ Note:
+ ----
+ This method performs a blocking HTTP request to fetch the remote
+ version file. In async context, consider using run_in_executor.
+ However, since this is called during startup and the file is tiny,
+ the blocking call is acceptable here.
+
+ """
+ local_version_path = self._cache_path / INDEX_VERSION_TXT
+
+ # Check if local version file exists
+ if not local_version_path.exists():
+ logger.debug("No local version file found at: %s", local_version_path)
+ return False
+
+ # Read local version
+ try:
+ local_version = local_version_path.read_text(encoding="utf-8").strip()
+ logger.debug("Local index version: %s", local_version)
+ except OSError as e:
+ logger.warning("Failed to read local version file: %s", e)
+ return False
+
+ # Fetch remote version
+ remote_version = self._fetch_remote_version()
+ if remote_version is None:
+ logger.warning("Could not fetch remote version, assuming cache miss")
+ return False
+
+ logger.debug("Remote index version: %s", remote_version)
+
+ # Compare versions
+ if local_version == remote_version:
+ logger.info(
+ "Version match: local=%s, remote=%s",
+ local_version,
+ remote_version,
+ )
+ return True
+
+ logger.info(
+ "Version mismatch: local=%s, remote=%s",
+ local_version,
+ remote_version,
+ )
+ return False
+
+ def _fetch_remote_version(self) -> str | None:
+ """Fetch the remote index version from HuggingFace.
+
+ Downloads only the index_version.txt file from the HuggingFace
+ repository to check if the local cache is still valid.
+
+ Returns:
+ -------
+ The remote version string, or None if fetch failed.
+
+ Note:
+ ----
+ This is a lightweight operation - only downloads a small text file.
+ Uses huggingface_hub's caching mechanism with force_download=True
+ to always get the latest version.
+
+ """
+ try:
+ # Lazy import huggingface_hub
+ from huggingface_hub import ( # type: ignore[attr-defined]
+ hf_hub_download,
+ )
+
+ # Download version file with force_download to skip HF's cache
+ version_path: str = hf_hub_download(
+ repo_id=self._settings.hf_index_repo,
+ filename=INDEX_VERSION_TXT,
+ repo_type="dataset",
+ token=self._settings.hf_token,
+ force_download=True, # Always get latest version
+ )
+ except ImportError:
+ logger.exception("huggingface_hub not installed")
+ return None
+ except Exception as e:
+ # Log as debug since this might be expected (new repo, no artifacts yet)
+ logger.debug("Failed to fetch remote version: %s", e)
+ return None
+ else:
+ # Read and return the version string
+ version_content = Path(version_path).read_text(encoding="utf-8").strip()
+ return version_content
+
+ async def _download_all_artifacts(self) -> None:
+ """Download all artifacts with retry logic.
+
+ Downloads all required artifact files from HuggingFace to the
+ local cache directory. Uses exponential backoff retry logic
+ for resilience against transient network failures.
+
+ Each file is downloaded using run_in_executor to avoid blocking
+ the async event loop, since huggingface_hub's download functions
+ are synchronous.
+
+ Raises
+ ------
+ ArtifactDownloadError: If any artifact fails to download after
+ all retry attempts.
+
+ """
+ total_start_time = time.perf_counter()
+ logger.info(
+ "Starting download of %d artifacts from %s",
+ len(ARTIFACT_FILES),
+ self._settings.hf_index_repo,
+ )
+
+ # Get the event loop for run_in_executor
+ loop = asyncio.get_running_loop()
+
+ # Download each artifact
+ for filename in ARTIFACT_FILES:
+ # Create a partial function for the blocking download
+ download_func = partial(self._download_single_artifact, filename)
+
+ # Run the blocking download in a thread pool executor
+ try:
+ downloaded_path = await loop.run_in_executor(None, download_func)
+ logger.info(
+ "Downloaded %s (%.2f KB)",
+ filename,
+ downloaded_path.stat().st_size / 1024,
+ )
+ except ArtifactDownloadError:
+ # Re-raise download errors
+ raise
+ except Exception as e:
+ # Wrap unexpected errors
+ msg = f"Unexpected error downloading {filename}"
+ raise ArtifactDownloadError(msg, filename=filename, cause=e) from e
+
+ total_elapsed = time.perf_counter() - total_start_time
+ logger.info(
+ "Downloaded all %d artifacts in %.2f seconds",
+ len(ARTIFACT_FILES),
+ total_elapsed,
+ )
+
+ # Log total size of all artifacts
+ self._log_cached_artifact_sizes()
+
+ def _download_single_artifact(self, filename: str) -> Path:
+ """Download a single artifact from HF with retries.
+
+ Downloads a single artifact file from HuggingFace to the local
+ cache directory. Implements exponential backoff retry logic
+ for resilience against transient failures.
+
+ Args:
+ ----
+ filename: Name of the artifact file to download
+ (e.g., "chunks.parquet", "faiss_index.bin").
+
+ Returns:
+ -------
+ Path to the downloaded file in the local cache directory.
+
+ Raises:
+ ------
+ ArtifactDownloadError: If download fails after MAX_RETRIES attempts.
+
+ """
+ # Lazy import huggingface_hub
+ from huggingface_hub import ( # type: ignore[attr-defined]
+ hf_hub_download,
+ )
+
+ last_error: Exception | None = None
+
+ for attempt in range(1, MAX_RETRIES + 1):
+ try:
+ start_time = time.perf_counter()
+
+ logger.debug(
+ "Downloading %s (attempt %d/%d)",
+ filename,
+ attempt,
+ MAX_RETRIES,
+ )
+
+ # Download using huggingface_hub
+ # local_dir puts the file directly in our cache directory
+ downloaded_path: str = hf_hub_download(
+ repo_id=self._settings.hf_index_repo,
+ filename=filename,
+ repo_type="dataset",
+ token=self._settings.hf_token,
+ local_dir=str(self._cache_path),
+ local_dir_use_symlinks=False, # Copy file, don't symlink
+ force_download=True, # Skip HF cache, always download fresh
+ )
+ except Exception as e:
+ last_error = e
+ logger.warning(
+ "Download attempt %d/%d failed for %s: %s",
+ attempt,
+ MAX_RETRIES,
+ filename,
+ e,
+ )
+
+ # Exponential backoff before retry (except on last attempt)
+ if attempt < MAX_RETRIES:
+ backoff_seconds = BASE_BACKOFF_SECONDS * (2 ** (attempt - 1))
+ logger.debug(
+ "Waiting %.1f seconds before retry",
+ backoff_seconds,
+ )
+ time.sleep(backoff_seconds)
+ else:
+ # Download succeeded - log timing and return path
+ elapsed = time.perf_counter() - start_time
+ file_path = Path(downloaded_path)
+ file_size_kb = file_path.stat().st_size / 1024
+
+ logger.debug(
+ "Downloaded %s: %.2f KB in %.2f seconds",
+ filename,
+ file_size_kb,
+ elapsed,
+ )
+
+ return file_path
+
+ # All retries exhausted
+ msg = f"Failed to download {filename} after {MAX_RETRIES} attempts"
+ raise ArtifactDownloadError(msg, filename=filename, cause=last_error)
+
+ def _log_cached_artifact_sizes(self) -> None:
+ """Log the sizes of all cached artifact files.
+
+ Iterates through all expected artifact files in the cache
+ directory and logs their sizes. Useful for debugging and
+ monitoring artifact completeness.
+
+ """
+ total_size_bytes = 0
+ artifact_sizes: list[str] = []
+
+ for filename in ARTIFACT_FILES:
+ file_path = self._cache_path / filename
+ if file_path.exists():
+ size_bytes = file_path.stat().st_size
+ total_size_bytes += size_bytes
+ artifact_sizes.append(f"{filename}: {size_bytes / 1024:.2f} KB")
+ else:
+ artifact_sizes.append(f"{filename}: MISSING")
+ logger.warning("Expected artifact missing: %s", file_path)
+
+ # Log individual file sizes at debug level
+ for size_info in artifact_sizes:
+ logger.debug(" %s", size_info)
+
+ # Log total size at info level
+ total_size_mb = total_size_bytes / (1024 * 1024)
+ logger.info("Total cached artifacts size: %.2f MB", total_size_mb)
+
+ def get_artifact_path(self, filename: str) -> Path:
+ """Get the path to a specific cached artifact.
+
+ Convenience method to construct the full path to a cached
+ artifact file without checking if it exists.
+
+ Args:
+ ----
+ filename: Name of the artifact file.
+
+ Returns:
+ -------
+ Full path to the artifact in the cache directory.
+
+ Example:
+ -------
+ >>> faiss_path = downloader.get_artifact_path("faiss_index.bin")
+ >>> print(faiss_path)
+ /app/.cache/artifacts/faiss_index.bin
+
+ """
+ return self._cache_path / filename
+
+ def is_cache_valid(self) -> bool:
+ """Check if all required artifacts exist in cache.
+
+ Verifies that all expected artifact files exist in the local
+ cache directory. Does NOT check version - only file existence.
+
+ Returns:
+ -------
+ True if all artifact files exist, False otherwise.
+
+ Example:
+ -------
+ >>> if downloader.is_cache_valid():
+ ... print("All artifacts present in cache")
+ ... else:
+ ... print("Some artifacts missing, need to download")
+
+ """
+ for filename in ARTIFACT_FILES:
+ file_path = self._cache_path / filename
+ if not file_path.exists():
+ logger.debug("Cache invalid: missing %s", filename)
+ return False
+ return True
diff --git a/src/rag_chatbot/api/freshness.py b/src/rag_chatbot/api/freshness.py
new file mode 100644
index 0000000000000000000000000000000000000000..ba85bca20f42ac061b9045471c82ed04ea9665b0
--- /dev/null
+++ b/src/rag_chatbot/api/freshness.py
@@ -0,0 +1,618 @@
+"""Dataset freshness validator for server startup.
+
+This module validates that downloaded RAG artifacts are consistent
+and compatible with the server's expected schema version. It provides:
+ - Loading and parsing of source_manifest.json from artifact cache
+ - Schema version validation against EXPECTED_SCHEMA_VERSION
+ - Comprehensive startup logging of dataset metadata
+ - Fast-fail behavior with clear error messages
+
+The FreshnessValidator is designed to be called during server startup
+before the RAG pipeline is initialized. If validation fails, the server
+should refuse to start, preventing undefined behavior from incompatible
+or corrupted artifacts.
+
+Validation Checks:
+ 1. Manifest file existence - source_manifest.json must be present
+ 2. Manifest parsing - JSON must be valid and match expected structure
+ 3. Schema version - Must match EXPECTED_SCHEMA_VERSION exactly
+ 4. Index version consistency - Logged for debugging
+
+Startup Logging:
+ The validator logs extensively at INFO level to provide visibility
+ into the dataset state during server startup. This includes:
+ - Dataset URL (HuggingFace repository)
+ - Index version
+ - Schema version
+ - Number of source files
+ - Total source file size
+ - Manifest creation timestamp
+
+Lazy Loading:
+ Heavy dependencies (Pydantic, JSON parsing) are loaded inside
+ methods to avoid import overhead at module load time. This follows
+ the project's lazy loading convention.
+
+Example:
+-------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.api.freshness import FreshnessValidator
+ >>> from rag_chatbot.config.settings import Settings
+ >>>
+ >>> settings = Settings()
+ >>> cache_path = Path(settings.artifact_cache_path)
+ >>> validator = FreshnessValidator(cache_path, settings)
+ >>>
+ >>> try:
+ ... manifest = validator.validate()
+ ... print(f"Validation passed! Index version: {manifest.index_version}")
+ ... except FreshnessValidationError as e:
+ ... print(f"Validation failed: {e}")
+ ... raise SystemExit(1)
+
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+if TYPE_CHECKING:
+ from rag_chatbot.api.manifest import SourceManifest
+ from rag_chatbot.config.settings import Settings
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "FreshnessValidator",
+ "FreshnessValidationError",
+]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+SOURCE_MANIFEST_JSON: Final[str] = "source_manifest.json"
+"""Filename for the source manifest JSON file in the artifact cache."""
+
+INDEX_VERSION_TXT: Final[str] = "index_version.txt"
+"""Filename for the index version text file in the artifact cache."""
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
+
+
+class FreshnessValidationError(Exception):
+ """Exception raised when dataset freshness validation fails.
+
+ This exception is raised when the FreshnessValidator detects an
+ issue with the downloaded artifacts that prevents safe operation.
+ The error includes detailed information to help diagnose the issue.
+
+ Common failure scenarios:
+ - Missing source_manifest.json file
+ - Invalid JSON in manifest file
+ - Schema version mismatch
+ - Missing required fields in manifest
+
+ Attributes:
+ ----------
+ message : str
+ Human-readable description of the validation failure.
+ expected_version : str | None
+ The schema version expected by the server (if applicable).
+ actual_version : str | None
+ The schema version found in the manifest (if applicable).
+ manifest_path : Path | None
+ Path to the manifest file that failed validation (if applicable).
+
+ Example:
+ -------
+ >>> try:
+ ... validator.validate()
+ ... except FreshnessValidationError as e:
+ ... print(f"Validation failed: {e.message}")
+ ... if e.expected_version:
+ ... print(f"Expected: {e.expected_version}")
+ ... print(f"Actual: {e.actual_version}")
+ ... if e.manifest_path:
+ ... print(f"Manifest: {e.manifest_path}")
+
+ """
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ expected_version: str | None = None,
+ actual_version: str | None = None,
+ manifest_path: Path | None = None,
+ ) -> None:
+ """Initialize a FreshnessValidationError.
+
+ Args:
+ ----
+ message: Human-readable description of the validation failure.
+ expected_version: Schema version expected by server (optional).
+ actual_version: Schema version found in manifest (optional).
+ manifest_path: Path to the manifest file (optional).
+
+ """
+ super().__init__(message)
+ self.message = message
+ self.expected_version = expected_version
+ self.actual_version = actual_version
+ self.manifest_path = manifest_path
+
+ def __str__(self) -> str:
+ """Return a string representation of the error.
+
+ Returns
+ -------
+ Formatted error message with version and path info if available.
+
+ """
+ parts = [self.message]
+
+ # Add version mismatch details if present
+ if self.expected_version is not None and self.actual_version is not None:
+ parts.append(
+ f"(expected schema: {self.expected_version}, "
+ f"actual schema: {self.actual_version})"
+ )
+
+ # Add manifest path if present
+ if self.manifest_path:
+ parts.append(f"[manifest: {self.manifest_path}]")
+
+ return " ".join(parts)
+
+
+# =============================================================================
+# FreshnessValidator Class
+# =============================================================================
+
+
+class FreshnessValidator:
+ """Validates dataset freshness and compatibility on server startup.
+
+ The FreshnessValidator checks that downloaded RAG artifacts are
+ compatible with the current server code by validating:
+ - Presence of source_manifest.json
+ - Valid JSON structure matching the SourceManifest model
+ - Schema version matches EXPECTED_SCHEMA_VERSION
+
+ If validation passes, the validator logs detailed metadata about
+ the dataset for debugging and monitoring purposes. If validation
+ fails, it raises FreshnessValidationError with actionable details.
+
+ This class is designed to be used during server startup, before
+ initializing the RAG pipeline. The validate() method should be
+ called after artifacts are downloaded but before they are loaded.
+
+ Attributes:
+ ----------
+ cache_path : Path
+ Path to the artifact cache directory.
+ settings : Settings | None
+ Application settings (optional, used for logging HF repo URL).
+
+ Example:
+ -------
+ >>> from pathlib import Path
+ >>> validator = FreshnessValidator(Path("/app/.cache/artifacts"))
+ >>> manifest = validator.validate() # Raises on failure
+ >>> print(f"Index version: {manifest.index_version}")
+ Index version: 2024.01.15.001
+
+ Note:
+ ----
+ The validator uses lazy imports for Pydantic models to avoid
+ loading them at module import time.
+
+ """
+
+ def __init__(
+ self,
+ cache_path: Path,
+ settings: Settings | None = None,
+ ) -> None:
+ """Initialize the FreshnessValidator.
+
+ Creates a new validator instance configured to check artifacts
+ in the specified cache directory.
+
+ Args:
+ ----
+ cache_path: Path to the artifact cache directory where
+ source_manifest.json and other artifacts are stored.
+ settings: Optional application settings. If provided, used
+ to include HF repository URL in log messages.
+
+ Example:
+ -------
+ >>> from pathlib import Path
+ >>> validator = FreshnessValidator(Path("/app/.cache/artifacts"))
+
+ """
+ self._cache_path = cache_path
+ self._settings = settings
+
+ logger.debug(
+ "Initialized FreshnessValidator: cache_path=%s",
+ cache_path,
+ )
+
+ @property
+ def cache_path(self) -> Path:
+ """Get the artifact cache directory path.
+
+ Returns
+ -------
+ Path to the artifact cache directory.
+
+ """
+ return self._cache_path
+
+ @property
+ def settings(self) -> Settings | None:
+ """Get the application settings.
+
+ Returns
+ -------
+ The Settings instance, or None if not provided.
+
+ """
+ return self._settings
+
+ @property
+ def manifest_path(self) -> Path:
+ """Get the path to the source manifest file.
+
+ Returns
+ -------
+ Full path to source_manifest.json in the cache directory.
+
+ """
+ return self._cache_path / SOURCE_MANIFEST_JSON
+
+ @property
+ def index_version_path(self) -> Path:
+ """Get the path to the index version file.
+
+ Returns
+ -------
+ Full path to index_version.txt in the cache directory.
+
+ """
+ return self._cache_path / INDEX_VERSION_TXT
+
+ def validate(self) -> SourceManifest:
+ """Validate the downloaded artifacts and return the manifest.
+
+ This is the main entry point for validation. It performs all
+ validation checks and logs dataset metadata on success. If any
+ check fails, it raises FreshnessValidationError with details.
+
+ Validation Steps:
+ 1. Load and parse source_manifest.json
+ 2. Validate schema version matches EXPECTED_SCHEMA_VERSION
+ 3. Log dataset metadata for visibility
+
+ Returns:
+ -------
+ SourceManifest: The validated manifest object containing
+ all metadata about the dataset.
+
+ Raises:
+ ------
+ FreshnessValidationError: If any validation check fails.
+ The exception includes details about the failure.
+
+ Example:
+ -------
+ >>> try:
+ ... manifest = validator.validate()
+ ... print(f"Validated! Version: {manifest.index_version}")
+ ... except FreshnessValidationError as e:
+ ... logger.error("Validation failed: %s", e)
+ ... raise SystemExit(1)
+
+ """
+ logger.info("Starting dataset freshness validation...")
+
+ # Step 1: Load and parse the manifest file
+ manifest = self._load_manifest()
+
+ # Step 2: Validate schema version compatibility
+ self._validate_schema_version(manifest)
+
+ # Step 3: Log dataset metadata for visibility
+ self._log_dataset_metadata(manifest)
+
+ logger.info("Dataset freshness validation PASSED")
+ return manifest
+
+ def _load_manifest(self) -> SourceManifest:
+ """Load and parse the source manifest from the cache.
+
+ Reads source_manifest.json from the artifact cache directory
+ and parses it into a SourceManifest Pydantic model. If the
+ file is missing or contains invalid JSON/structure, raises
+ FreshnessValidationError with details.
+
+ Returns
+ -------
+ SourceManifest: Parsed manifest object.
+
+ Raises
+ ------
+ FreshnessValidationError: If manifest file is missing,
+ contains invalid JSON, or doesn't match the expected
+ model structure.
+
+ """
+ # Lazy import to avoid loading Pydantic at module import time
+ from rag_chatbot.api.manifest import SourceManifest
+
+ manifest_path = self.manifest_path
+
+ # Check if manifest file exists
+ if not manifest_path.exists():
+ msg = (
+ f"Source manifest file not found: {manifest_path}. "
+ "This may indicate the dataset needs to be rebuilt. "
+ "Ensure the build pipeline generates source_manifest.json."
+ )
+ logger.error(msg)
+ raise FreshnessValidationError(msg, manifest_path=manifest_path)
+
+ logger.debug("Loading manifest from: %s", manifest_path)
+
+ # Read and parse JSON
+ try:
+ manifest_text = manifest_path.read_text(encoding="utf-8")
+ manifest_data = json.loads(manifest_text)
+ except json.JSONDecodeError as e:
+ msg = (
+ f"Invalid JSON in source manifest file: {e}. "
+ "The manifest file may be corrupted. "
+ "Try re-downloading artifacts or rebuilding the dataset."
+ )
+ raise FreshnessValidationError(msg, manifest_path=manifest_path) from e
+ except OSError as e:
+ msg = (
+ f"Failed to read source manifest file: {e}. "
+ "Check file permissions and disk space."
+ )
+ raise FreshnessValidationError(msg, manifest_path=manifest_path) from e
+
+ # Parse into Pydantic model
+ try:
+ manifest = SourceManifest.model_validate(manifest_data)
+ except Exception as e:
+ # Catch Pydantic ValidationError - wrap with actionable error message
+ msg = (
+ f"Failed to parse source manifest: {e}. "
+ "The manifest structure may be incompatible with this server version. "
+ "Check if the dataset was built with a compatible version "
+ "of the build pipeline."
+ )
+ raise FreshnessValidationError(msg, manifest_path=manifest_path) from e
+
+ logger.debug(
+ "Loaded manifest: schema_version=%s, index_version=%s, files=%d",
+ manifest.schema_version,
+ manifest.index_version,
+ manifest.source_file_count,
+ )
+
+ return manifest
+
+ def _validate_schema_version(self, manifest: SourceManifest) -> None:
+ """Validate that the manifest schema version is compatible.
+
+ Compares the manifest's schema_version with EXPECTED_SCHEMA_VERSION.
+ If they don't match exactly, raises FreshnessValidationError.
+
+ Currently uses strict equality matching. Future versions may
+ implement semantic version comparison for backward compatibility.
+
+ Args:
+ ----
+ manifest: The loaded SourceManifest to validate.
+
+ Raises:
+ ------
+ FreshnessValidationError: If schema versions don't match.
+
+ """
+ # Lazy import to avoid loading at module import time
+ from rag_chatbot.api.manifest import EXPECTED_SCHEMA_VERSION
+
+ actual_version = manifest.schema_version
+ expected_version = EXPECTED_SCHEMA_VERSION
+
+ if actual_version != expected_version:
+ msg = (
+ f"Schema version mismatch: expected '{expected_version}', "
+ f"found '{actual_version}'. "
+ "The dataset was built with an incompatible version of the "
+ "build pipeline. Please rebuild the dataset with the current "
+ "version of the codebase, or update the server to a compatible version."
+ )
+ raise FreshnessValidationError(
+ msg,
+ expected_version=expected_version,
+ actual_version=actual_version,
+ manifest_path=self.manifest_path,
+ )
+
+ logger.debug(
+ "Schema version validated: %s (matches expected)",
+ actual_version,
+ )
+
+ def _log_dataset_metadata(self, manifest: SourceManifest) -> None:
+ """Log detailed metadata about the validated dataset.
+
+ This method logs comprehensive information about the dataset
+ at INFO level for visibility during server startup. The logs
+ help with debugging, monitoring, and auditing.
+
+ Logged Information:
+ - Dataset URL (HuggingFace repository, if settings provided)
+ - Index version
+ - Schema version
+ - Manifest creation timestamp
+ - Number of source files
+ - Total source file size
+
+ Args:
+ ----
+ manifest: The validated SourceManifest to log details from.
+
+ """
+ # Size thresholds for human-readable formatting
+ bytes_per_kb = 1024
+ bytes_per_mb = bytes_per_kb * bytes_per_kb
+
+ # Build the log message with all available metadata
+ log_lines = [
+ "=== Dataset Metadata ===",
+ ]
+
+ # Add HF repository URL if settings are available
+ if self._settings is not None:
+ hf_repo = self._settings.hf_index_repo
+ log_lines.append(
+ f" Dataset URL: https://huggingface.co/datasets/{hf_repo}"
+ )
+
+ # Add index version
+ log_lines.append(f" Index Version: {manifest.index_version}")
+
+ # Add schema version
+ log_lines.append(f" Schema Version: {manifest.schema_version}")
+
+ # Add creation timestamp
+ log_lines.append(f" Created At: {manifest.created_at.isoformat()}")
+
+ # Add source file statistics
+ log_lines.append(f" Source Files: {manifest.source_file_count}")
+
+ # Calculate and format total size with human-readable units
+ total_bytes = manifest.total_source_size_bytes
+ if total_bytes >= bytes_per_mb:
+ # Format as MB for large files
+ total_size_str = f"{total_bytes / bytes_per_mb:.2f} MB"
+ elif total_bytes >= bytes_per_kb:
+ # Format as KB for medium files
+ total_size_str = f"{total_bytes / bytes_per_kb:.2f} KB"
+ else:
+ # Format as bytes for small files
+ total_size_str = f"{total_bytes} bytes"
+
+ log_lines.append(f" Total Source Size: {total_size_str}")
+ log_lines.append("========================")
+
+ # Log as a single multi-line message
+ logger.info("\n".join(log_lines))
+
+ def get_index_version(self) -> str | None:
+ """Read the index version from index_version.txt.
+
+ This is a convenience method to read the index version directly
+ from the text file without loading the full manifest. Useful
+ for quick version checks or when manifest validation is not needed.
+
+ Returns:
+ -------
+ str | None: The index version string if the file exists and
+ is readable, None otherwise.
+
+ Example:
+ -------
+ >>> version = validator.get_index_version()
+ >>> if version:
+ ... print(f"Index version: {version}")
+ ... else:
+ ... print("Index version file not found")
+
+ """
+ version_path = self.index_version_path
+
+ if not version_path.exists():
+ logger.debug("Index version file not found: %s", version_path)
+ return None
+
+ try:
+ version = version_path.read_text(encoding="utf-8").strip()
+ except OSError as e:
+ logger.warning("Failed to read index version file: %s", e)
+ return None
+ else:
+ logger.debug("Read index version: %s", version)
+ return version if version else None
+
+ def check_version_consistency(self) -> bool:
+ """Check if index version matches between manifest and text file.
+
+ Compares the index_version in source_manifest.json with the
+ content of index_version.txt. They should always match if the
+ build pipeline worked correctly.
+
+ This is useful for detecting partial or corrupted downloads
+ where some files may have been updated but not others.
+
+ Returns:
+ -------
+ bool: True if versions match, False if they differ or if
+ either file is missing/unreadable.
+
+ Example:
+ -------
+ >>> if not validator.check_version_consistency():
+ ... logger.warning("Version inconsistency detected!")
+ ... # Consider forcing a refresh
+
+ """
+ # Read index version from text file
+ txt_version = self.get_index_version()
+ if txt_version is None:
+ logger.debug("Cannot check consistency: index_version.txt not readable")
+ return False
+
+ # Try to load manifest to get its version
+ try:
+ manifest = self._load_manifest()
+ manifest_version = manifest.index_version
+ except FreshnessValidationError:
+ logger.debug("Cannot check consistency: manifest not loadable")
+ return False
+
+ # Compare versions
+ if txt_version == manifest_version:
+ logger.debug(
+ "Version consistency check passed: %s",
+ txt_version,
+ )
+ return True
+
+ logger.warning(
+ "Version inconsistency detected: "
+ "index_version.txt=%s, manifest.index_version=%s",
+ txt_version,
+ manifest_version,
+ )
+ return False
diff --git a/src/rag_chatbot/api/main.py b/src/rag_chatbot/api/main.py
index d796106d443fd1096f779cc1fa1c1caea3ed1c2a..2f60fd9252133ec2de75ee033c4afcca42cfb5b1 100644
--- a/src/rag_chatbot/api/main.py
+++ b/src/rag_chatbot/api/main.py
@@ -7,56 +7,387 @@ and configuring the FastAPI application. The application includes:
- Route mounting for query and health endpoints
- Lifespan management for startup/shutdown
+Architecture Overview:
+ The application uses the factory pattern to enable:
+ - Lazy loading of heavy dependencies (FastAPI, Starlette)
+ - Configuration injection for testing
+ - Multiple application instances if needed
+
+Lifespan Management:
+ The application uses FastAPI's lifespan context manager to handle:
+ - Startup: Initialize logging, load retrieval indexes (lazy)
+ - Shutdown: Clean up resources, flush pending logs
+
+CORS Configuration:
+ CORS origins are loaded from the Settings class, which reads
+ from the CORS_ORIGINS environment variable. This allows
+ flexible configuration for different deployment environments.
+
Lazy Loading:
FastAPI is loaded on first use to avoid import overhead.
+ This is especially important for CLI tools that import
+ the module but may not need the web server.
-Note:
-----
- This is a placeholder that will be fully implemented in Step 4.1.
+Example:
+-------
+ >>> from rag_chatbot.api import create_app
+ >>> app = create_app()
+ >>> # Run with uvicorn
+ >>> import uvicorn
+ >>> uvicorn.run(app, host="0.0.0.0", port=8000)
"""
from __future__ import annotations
-from typing import Any
+import logging
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncIterator
+
+ from fastapi import FastAPI
# =============================================================================
# Module Exports
# =============================================================================
__all__: list[str] = ["create_app"]
+# =============================================================================
+# Module-level Logger
+# =============================================================================
+# Logger is configured at module level for use in lifespan events.
+# The actual log level is set by the application configuration.
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Lifespan Context Manager
+# =============================================================================
+@asynccontextmanager
+async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
+ """Manage application lifespan events for startup and shutdown.
+
+ This async context manager handles the application lifecycle:
+
+ Startup Phase:
+ - Log application startup with version info
+ - Initialize ResourceManager singleton (lazy loading deferred)
+ - Store ResourceManager in app.state for route handler access
+
+ Shutdown Phase:
+ - Log application shutdown
+ - Call ResourceManager.shutdown() to clean up resources
+ - Log shutdown completion
+
+ The lifespan pattern replaces the deprecated @app.on_event decorators
+ and provides cleaner resource management with proper cleanup guarantees.
+
+ Resource Loading Strategy:
+ Resources are NOT loaded during startup. The ResourceManager is
+ initialized but resources (retriever, settings) load lazily on
+ the first request via ensure_loaded(). This enables:
+ - Fast application startup (no heavy loading)
+ - Minimal memory usage until first request
+ - Clean cold start metrics
+
+ Args:
+ ----
+ app: The FastAPI application instance. Used to access app state
+ for storing shared resources like the ResourceManager.
+
+ Yields:
+ ------
+ None. The context manager yields control to the application
+ after startup and regains control on shutdown.
+
+ Example:
+ -------
+ This is used internally by create_app() and should not be
+ called directly:
+
+ >>> app = FastAPI(lifespan=_lifespan)
+
+ """
+ # =========================================================================
+ # Startup Phase
+ # =========================================================================
+ logger.info(
+ "Starting Pythermalcomfort RAG Chatbot API (version %s)",
+ "0.1.0",
+ )
+
+ # =========================================================================
+ # Initialize Resource Manager (lazy loading deferred to first request)
+ # =========================================================================
+ # Import ResourceManager here to avoid loading heavy dependencies at
+ # module import time. The ResourceManager itself uses lazy loading.
+ # =========================================================================
+ from rag_chatbot.api.resources import get_resource_manager
+
+ resource_manager = get_resource_manager()
+
+ # Store in app.state for access by route handlers
+ # Route handlers will call ensure_loaded() before using resources
+ app.state.resource_manager = resource_manager
+
+ logger.info("Resource manager initialized (resources will load on first request)")
+ # =========================================================================
+ # Initialize Query Logging Service
+ # =========================================================================
+ # Start the query logging service for async logging to HuggingFace.
+ # This is non-blocking and will not affect application startup if it fails.
+ # =========================================================================
+ from rag_chatbot.qlog.service import on_startup as qlog_on_startup
+
+ await qlog_on_startup()
+
+ # Yield control to the application - this is where requests are served
+ yield
+
+ # =========================================================================
+ # Shutdown Phase
+ # =========================================================================
+ logger.info("Shutting down Pythermalcomfort RAG Chatbot API")
+
+ # =========================================================================
+ # Stop Query Logging Service
+ # =========================================================================
+ # Stop the query logging service and flush any pending logs to HuggingFace.
+ # This ensures all logged queries are persisted before shutdown.
+ # =========================================================================
+ from rag_chatbot.qlog.service import on_shutdown as qlog_on_shutdown
+
+ await qlog_on_shutdown()
+
+ # Clean up resources (flush logs, release memory)
+ await resource_manager.shutdown()
+
+ logger.info("Application shutdown complete")
+
+
+# =============================================================================
+# Application Factory
+# =============================================================================
def create_app() -> Any: # noqa: ANN401 - Returns FastAPI instance
"""Create and configure the FastAPI application.
- This factory function creates a FastAPI application with:
- - CORS middleware configured for frontend origins
- - Error handling middleware for graceful error responses
- - Query routes for chat functionality
- - Health routes for monitoring
+ This factory function creates a fully configured FastAPI application
+ with all middleware, routes, and lifespan management set up.
+
+ Application Components:
+ 1. Lifespan Management: Handles startup/shutdown events for
+ resource initialization and cleanup.
+
+ 2. CORS Middleware: Configured from settings to allow frontend
+ access from specified origins.
+
+ 3. OpenAPI Documentation: Available at /docs with full API
+ schema and interactive testing.
+
+ 4. Route Handlers:
+ - Health routes: /health, /health/ready for monitoring
+ - Query routes: /query for chat functionality
- The application uses lifespan events to:
- - Load retrieval indexes on startup
- - Clean up resources on shutdown
+ Configuration:
+ The application reads configuration from the Settings class,
+ which loads from environment variables. Key settings:
+ - CORS_ORIGINS: Comma-separated list of allowed origins
+ - LOG_LEVEL: Logging verbosity (DEBUG, INFO, etc.)
+ - Other settings for retrieval and LLM configuration
Returns:
-------
- Configured FastAPI application instance.
+ FastAPI: Configured FastAPI application instance ready to be
+ run with uvicorn or another ASGI server.
Example:
-------
>>> app = create_app()
- >>> # Run with uvicorn
+ >>> # Run with uvicorn programmatically
>>> import uvicorn
>>> uvicorn.run(app, host="0.0.0.0", port=8000)
+ Or from command line:
+ >>> # uvicorn rag_chatbot.api:create_app --factory --host 0.0.0.0
+
Note:
----
- This function will be fully implemented in Phase 4 (Step 4.1).
-
- Raises:
- ------
- NotImplementedError: create_app will be implemented in Step 4.1.
+ This function uses lazy imports to defer loading of FastAPI
+ and related dependencies until the application is actually
+ created. This improves import performance for CLI tools.
"""
- raise NotImplementedError("create_app() will be implemented in Step 4.1")
+ # =========================================================================
+ # Lazy Import Dependencies
+ # =========================================================================
+ # Import FastAPI and middleware only when creating the app.
+ # This avoids loading these heavy dependencies at module import time.
+ from fastapi import FastAPI
+ from fastapi.middleware.cors import CORSMiddleware
+
+ # Import settings for CORS configuration
+ from rag_chatbot.config.settings import Settings
+
+ # Import route handlers from the routes submodule
+ # Note: These are currently placeholder routers (router = None)
+ # and will be fully implemented in subsequent steps.
+ from .routes.health import router as health_router
+ from .routes.providers import router as providers_router
+ from .routes.query import router as query_router
+
+ # =========================================================================
+ # Load Configuration
+ # =========================================================================
+ # Create settings instance to load configuration from environment.
+ # Settings uses Pydantic BaseSettings for validation and defaults.
+ settings = Settings()
+
+ # Log the configuration being used (at debug level to avoid secrets)
+ logger.debug(
+ "Creating app with CORS origins: %s",
+ settings.cors_origins,
+ )
+
+ # =========================================================================
+ # Create FastAPI Application
+ # =========================================================================
+ # Create the FastAPI app with OpenAPI documentation configuration.
+ # The lifespan parameter handles startup/shutdown events.
+ app = FastAPI(
+ # ---------------------------------------------------------------------
+ # OpenAPI Metadata
+ # ---------------------------------------------------------------------
+ # These settings configure the /docs endpoint and OpenAPI schema.
+ title="Pythermalcomfort RAG Chatbot API",
+ version="0.1.0",
+ description=(
+ "A Retrieval-Augmented Generation (RAG) chatbot API for the "
+ "pythermalcomfort library. This API provides:\n\n"
+ "- **Query Endpoint**: Ask questions about pythermalcomfort and "
+ "receive AI-generated answers with source citations.\n"
+ "- **Streaming Responses**: Real-time response streaming via "
+ "Server-Sent Events (SSE).\n"
+ "- **Health Checks**: Endpoints for monitoring application status "
+ "and readiness.\n\n"
+ "The chatbot uses hybrid retrieval (dense embeddings + BM25) to "
+ "find relevant documentation chunks, then generates responses "
+ "using a multi-provider LLM fallback chain."
+ ),
+ # ---------------------------------------------------------------------
+ # Lifespan Management
+ # ---------------------------------------------------------------------
+ # Use the lifespan context manager for startup/shutdown events.
+ # This replaces the deprecated @app.on_event decorators.
+ lifespan=_lifespan,
+ # ---------------------------------------------------------------------
+ # Documentation URLs
+ # ---------------------------------------------------------------------
+ # Enable the /docs endpoint for interactive API documentation.
+ # The /redoc endpoint is also available by default.
+ docs_url="/docs",
+ redoc_url="/redoc",
+ openapi_url="/openapi.json",
+ )
+
+ # =========================================================================
+ # Configure CORS Middleware
+ # =========================================================================
+ # Add CORS middleware to allow frontend access from specified origins.
+ # This is essential for the Next.js frontend to communicate with the API.
+ #
+ # The CORS configuration:
+ # - allow_origins: List of allowed origins from settings
+ # - allow_credentials: Allow cookies and auth headers
+ # - allow_methods: Allow all HTTP methods (GET, POST, etc.)
+ # - allow_headers: Allow all headers for flexibility
+ app.add_middleware(
+ CORSMiddleware,
+ # Origins allowed to make requests to this API.
+ # Loaded from CORS_ORIGINS environment variable via settings.
+ allow_origins=settings.cors_origins,
+ # Allow credentials (cookies, authorization headers).
+ # Required for authenticated requests from the frontend.
+ allow_credentials=True,
+ # Allow all HTTP methods.
+ # The API uses GET for health checks and POST for queries.
+ allow_methods=["*"],
+ # Allow all headers.
+ # This enables custom headers like Content-Type and Authorization.
+ allow_headers=["*"],
+ )
+
+ # =========================================================================
+ # Mount Route Handlers
+ # =========================================================================
+ # Include the route handlers for different API functionality.
+ # Each router handles a specific set of endpoints.
+ #
+ # Note: The routers are currently placeholders (router = None).
+ # They will be fully implemented in subsequent steps:
+ # - health router: Step 7.3
+ # - query router: Step 7.4
+
+ # Health check routes for monitoring and readiness probes.
+ # These endpoints are used by:
+ # - Load balancers to check if the service is healthy
+ # - Kubernetes to determine pod readiness
+ # - Monitoring systems to track service status
+ if health_router is not None:
+ app.include_router(
+ health_router, # type: ignore[arg-type]
+ prefix="/health",
+ tags=["Health"],
+ )
+ else:
+ # Log warning if health router is not yet implemented
+ logger.warning(
+ "Health router is None - health endpoints not mounted. "
+ "This will be implemented in a subsequent step."
+ )
+
+ # Query routes for chat functionality.
+ # The query endpoint handles:
+ # - Receiving user questions
+ # - Retrieving relevant context from the document store
+ # - Generating responses using LLM providers
+ # - Streaming responses via SSE
+ if query_router is not None:
+ app.include_router(
+ query_router, # type: ignore[arg-type]
+ prefix="/api",
+ tags=["Query"],
+ )
+ else:
+ # Log warning if query router is not yet implemented
+ logger.warning(
+ "Query router is None - query endpoints not mounted. "
+ "This will be implemented in a subsequent step."
+ )
+
+ # Provider status routes for monitoring LLM provider availability.
+ # The providers endpoint handles:
+ # - Returning status of all configured LLM providers (Gemini, Groq)
+ # - Per-model quota information (RPM, TPM, RPD, TPD)
+ # - Cooldown status after rate limit errors
+ # - Response caching (1 minute TTL) to reduce quota check overhead
+ if providers_router is not None:
+ app.include_router(
+ providers_router, # type: ignore[arg-type]
+ prefix="/api",
+ tags=["Providers"],
+ )
+ else:
+ # Log warning if providers router is not yet implemented
+ logger.warning(
+ "Providers router is None - provider status endpoints not mounted. "
+ "This will be implemented in a subsequent step."
+ )
+
+ # =========================================================================
+ # Return Configured Application
+ # =========================================================================
+ logger.info("FastAPI application created successfully")
+ return app
diff --git a/src/rag_chatbot/api/manifest.py b/src/rag_chatbot/api/manifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..4dbac42662b015817e0019482c26d6ff494b96b2
--- /dev/null
+++ b/src/rag_chatbot/api/manifest.py
@@ -0,0 +1,955 @@
+"""Source manifest model for dataset freshness validation.
+
+This module defines the Pydantic models for source_manifest.json which
+tracks source file hashes and metadata for cache invalidation and
+dataset integrity verification.
+
+The source manifest is generated during the build pipeline and stored
+alongside the RAG artifacts in the HuggingFace dataset. On server startup,
+the manifest is loaded and validated to ensure:
+ 1. Schema version compatibility (forward/backward migrations)
+ 2. Artifact integrity (file hashes match expected values)
+ 3. Build metadata is available for debugging
+
+Manifest Structure:
+ The manifest contains:
+ - Schema version for future migrations
+ - Creation timestamp for the build
+ - Index version identifier (matches index_version.txt)
+ - List of source files with hashes and metadata
+
+Schema Versioning:
+ The EXPECTED_SCHEMA_VERSION constant defines the schema version that
+ this server code expects. If the downloaded manifest has a different
+ schema version, validation will fail with a clear error message
+ indicating the version mismatch.
+
+Lazy Loading:
+ Pydantic is imported inside a factory function to avoid import
+ overhead at module load time. This follows the project's lazy
+ loading pattern used throughout the codebase.
+
+Example:
+-------
+ >>> from rag_chatbot.api.manifest import SourceManifest, SourceFileEntry
+ >>> from datetime import datetime, UTC
+ >>>
+ >>> # Create a source file entry
+ >>> file_entry = SourceFileEntry(
+ ... path="data/raw/ashrae_55.pdf",
+ ... sha256="abc123...",
+ ... size_bytes=1024000,
+ ... modified_at=datetime.now(UTC),
+ ... )
+ >>>
+ >>> # Create a manifest
+ >>> manifest = SourceManifest(
+ ... schema_version="1.0.0",
+ ... created_at=datetime.now(UTC),
+ ... index_version="2024.01.15.001",
+ ... source_files=[file_entry],
+ ... )
+ >>> manifest.schema_version
+ '1.0.0'
+
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from datetime import datetime
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "SourceManifest",
+ "SourceFileEntry",
+ "ManifestValidationError",
+ "EXPECTED_SCHEMA_VERSION",
+]
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+EXPECTED_SCHEMA_VERSION: str = "1.0.0"
+"""Expected schema version for source_manifest.json.
+
+This constant defines the schema version that the server expects.
+If the downloaded manifest has a different schema version, the
+server will fail to start with a clear error message.
+
+Versioning scheme follows semantic versioning (MAJOR.MINOR.PATCH):
+ - MAJOR: Breaking changes that require code changes to handle
+ - MINOR: Backward-compatible additions (new optional fields)
+ - PATCH: Backward-compatible bug fixes (documentation, etc.)
+
+History:
+ - 1.0.0: Initial schema version with core fields
+"""
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
+
+
+class ManifestValidationError(Exception):
+ """Exception raised when manifest validation fails.
+
+ This exception is raised when the source manifest fails validation
+ due to schema version mismatch, missing required fields, or invalid
+ field values.
+
+ The exception includes detailed information to help diagnose the
+ issue, including expected vs actual schema versions when applicable.
+
+ Attributes:
+ ----------
+ message : str
+ Human-readable description of the validation failure.
+ expected_version : str | None
+ The schema version expected by the server (if applicable).
+ actual_version : str | None
+ The schema version found in the manifest (if applicable).
+ field_name : str | None
+ Name of the field that failed validation (if applicable).
+
+ Example:
+ -------
+ >>> try:
+ ... validator.validate()
+ ... except ManifestValidationError as e:
+ ... print(f"Validation failed: {e.message}")
+ ... if e.expected_version:
+ ... print(f"Expected version: {e.expected_version}")
+ ... print(f"Actual version: {e.actual_version}")
+
+ """
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ expected_version: str | None = None,
+ actual_version: str | None = None,
+ field_name: str | None = None,
+ ) -> None:
+ """Initialize a ManifestValidationError.
+
+ Args:
+ ----
+ message: Human-readable description of the validation failure.
+ expected_version: Schema version expected by server (optional).
+ actual_version: Schema version found in manifest (optional).
+ field_name: Name of the field that failed validation (optional).
+
+ """
+ super().__init__(message)
+ self.message = message
+ self.expected_version = expected_version
+ self.actual_version = actual_version
+ self.field_name = field_name
+
+ def __str__(self) -> str:
+ """Return a string representation of the error.
+
+ Returns
+ -------
+ Formatted error message with version and field info if available.
+
+ """
+ parts = [self.message]
+
+ # Add version mismatch details if present
+ if self.expected_version is not None and self.actual_version is not None:
+ parts.append(
+ f"(expected: {self.expected_version}, actual: {self.actual_version})"
+ )
+
+ # Add field name if present
+ if self.field_name:
+ parts.append(f"[field: {self.field_name}]")
+
+ return " ".join(parts)
+
+
+# =============================================================================
+# Pydantic Model Factory (Lazy Loading)
+# =============================================================================
+# This factory function creates the Pydantic models lazily to avoid
+# importing Pydantic at module load time. This follows the project's
+# lazy loading pattern used throughout the codebase.
+# =============================================================================
+
+
+def _create_source_file_entry_model() -> type:
+ """Create the SourceFileEntry Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time.
+
+ Returns
+ -------
+ type: The SourceFileEntry Pydantic model class.
+
+ """
+ # Import datetime for Pydantic's runtime type resolution
+ from datetime import datetime # noqa: F401 - Used by Pydantic field annotation
+
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+ class _SourceFileEntry(BaseModel):
+ """Model for a single source file entry in the manifest.
+
+ Each SourceFileEntry represents one source file that was used
+ to build the RAG index. It includes the file path, content hash,
+ size, and modification timestamp for verification and debugging.
+
+ The SHA256 hash enables verification that source files haven't
+ changed since the index was built. If source files change, a
+ rebuild is required to keep the index in sync.
+
+ Attributes:
+ ----------
+ path : str
+ Relative path to the source file from the project root.
+ Example: "data/raw/ashrae_55.pdf"
+
+ sha256 : str
+ SHA256 hash of the file content (64 hex characters).
+ Used for integrity verification and change detection.
+
+ size_bytes : int
+ File size in bytes. Useful for debugging and validation.
+ Must be non-negative.
+
+ modified_at : datetime
+ Last modification timestamp of the source file.
+ Should be in UTC timezone for consistency.
+
+ Example:
+ -------
+ >>> from datetime import datetime, UTC
+ >>> entry = _SourceFileEntry(
+ ... path="data/raw/ashrae_55.pdf",
+ ... sha256="abc123def456...",
+ ... size_bytes=1024000,
+ ... modified_at=datetime.now(UTC),
+ ... )
+ >>> entry.path
+ 'data/raw/ashrae_55.pdf'
+
+ """
+
+ # =====================================================================
+ # Model Configuration
+ # =====================================================================
+ model_config = ConfigDict(
+ # Forbid extra fields to catch typos in manifest files
+ extra="forbid",
+ # Make instances immutable for thread-safety
+ frozen=True,
+ # Enable JSON schema generation with examples
+ json_schema_extra={
+ "examples": [
+ {
+ "path": "data/raw/ashrae_55.pdf",
+ # Example SHA256 hash (64 hex characters)
+ "sha256": (
+ "e3b0c44298fc1c149afbf4c8996fb924"
+ "27ae41e4649b934ca495991b7852b855"
+ ),
+ "size_bytes": 1048576,
+ "modified_at": "2024-01-15T10:30:00Z",
+ }
+ ]
+ },
+ )
+
+ # =====================================================================
+ # Fields
+ # =====================================================================
+
+ path: str = Field(
+ ..., # Required field
+ min_length=1,
+ description=(
+ "Relative path to the source file from project root. "
+ "Example: 'data/raw/ashrae_55.pdf'"
+ ),
+ )
+
+ sha256: str = Field(
+ ..., # Required field
+ min_length=64,
+ max_length=64,
+ pattern=r"^[a-f0-9]{64}$",
+ description=(
+ "SHA256 hash of the file content as 64 lowercase hex characters. "
+ "Used for integrity verification and change detection."
+ ),
+ )
+
+ size_bytes: int = Field(
+ ..., # Required field
+ ge=0, # File size must be non-negative
+ description="File size in bytes. Must be non-negative.",
+ )
+
+ modified_at: datetime = Field(
+ ..., # Required field
+ description=(
+ "Last modification timestamp of the source file. "
+ "Should be in UTC timezone for consistency."
+ ),
+ )
+
+ # =====================================================================
+ # Validators
+ # =====================================================================
+
+ @field_validator("path", mode="before")
+ @classmethod
+ def _normalize_path(cls, value: object) -> str:
+ """Normalize the path field by stripping whitespace.
+
+ Args:
+ ----
+ value: The input value to normalize.
+
+ Returns:
+ -------
+ Stripped path string.
+
+ Raises:
+ ------
+ ValueError: If value is None or empty after stripping.
+
+ """
+ if value is None:
+ msg = "path cannot be None"
+ raise ValueError(msg)
+
+ path = str(value).strip()
+
+ if not path:
+ msg = "path cannot be empty"
+ raise ValueError(msg)
+
+ return path
+
+ @field_validator("sha256", mode="before")
+ @classmethod
+ def _normalize_sha256(cls, value: object) -> str:
+ """Normalize the sha256 field to lowercase.
+
+ Args:
+ ----
+ value: The input value to normalize.
+
+ Returns:
+ -------
+ Lowercase sha256 string.
+
+ Raises:
+ ------
+ ValueError: If value is None or invalid format.
+
+ """
+ if value is None:
+ msg = "sha256 cannot be None"
+ raise ValueError(msg)
+
+ # Convert to lowercase string and strip whitespace
+ sha256 = str(value).strip().lower()
+
+ if not sha256:
+ msg = "sha256 cannot be empty"
+ raise ValueError(msg)
+
+ return sha256
+
+ return _SourceFileEntry
+
+
+def _create_source_manifest_model(source_file_entry_class: type) -> type:
+ """Create the SourceManifest Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time.
+
+ Args:
+ ----
+ source_file_entry_class: The SourceFileEntry model class to use
+ for the source_files field type annotation.
+
+ Returns:
+ -------
+ type: The SourceManifest Pydantic model class.
+
+ """
+ # Import datetime for Pydantic's runtime type resolution
+ from datetime import datetime # noqa: F401 - Used by Pydantic field annotation
+
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+ class _SourceManifest(BaseModel):
+ """Model for the source_manifest.json file.
+
+ The SourceManifest tracks metadata about the source files and
+ build process for the RAG index. It enables:
+ - Schema version validation for compatibility checking
+ - Build timestamp tracking for debugging
+ - Index version matching with index_version.txt
+ - Source file tracking for change detection
+
+ This manifest is generated during the build pipeline and stored
+ alongside the RAG artifacts in the HuggingFace dataset.
+
+ Attributes:
+ ----------
+ schema_version : str
+ Schema version of this manifest (e.g., "1.0.0").
+ Must match EXPECTED_SCHEMA_VERSION for validation to pass.
+
+ created_at : datetime
+ When this manifest was generated (build timestamp).
+ Should be in UTC timezone for consistency.
+
+ index_version : str
+ Index version identifier that matches index_version.txt.
+ Used for cache invalidation and version tracking.
+
+ source_files : list[SourceFileEntry]
+ List of source files used to build the index.
+ Each entry includes path, hash, size, and timestamp.
+
+ Example:
+ -------
+ >>> from datetime import datetime, UTC
+ >>> manifest = _SourceManifest(
+ ... schema_version="1.0.0",
+ ... created_at=datetime.now(UTC),
+ ... index_version="2024.01.15.001",
+ ... source_files=[],
+ ... )
+ >>> manifest.schema_version
+ '1.0.0'
+
+ """
+
+ # =====================================================================
+ # Model Configuration
+ # =====================================================================
+ model_config = ConfigDict(
+ # Forbid extra fields to catch typos in manifest files
+ extra="forbid",
+ # Make instances immutable for thread-safety
+ frozen=True,
+ # Enable JSON schema generation with examples
+ json_schema_extra={
+ "examples": [
+ {
+ "schema_version": "1.0.0",
+ "created_at": "2024-01-15T10:30:00Z",
+ "index_version": "2024.01.15.001",
+ "source_files": [
+ {
+ "path": "data/raw/ashrae_55.pdf",
+ "sha256": "e3b0c44...",
+ "size_bytes": 1048576,
+ "modified_at": "2024-01-15T10:30:00Z",
+ }
+ ],
+ }
+ ]
+ },
+ )
+
+ # =====================================================================
+ # Fields
+ # =====================================================================
+
+ schema_version: str = Field(
+ ..., # Required field
+ min_length=1,
+ pattern=r"^\d+\.\d+\.\d+$", # Semantic versioning format
+ description=(
+ "Schema version of this manifest (semantic versioning). "
+ "Example: '1.0.0'. Must match EXPECTED_SCHEMA_VERSION."
+ ),
+ )
+
+ created_at: datetime = Field(
+ ..., # Required field
+ description=(
+ "When this manifest was generated (build timestamp). "
+ "Should be in UTC timezone for consistency."
+ ),
+ )
+
+ index_version: str = Field(
+ ..., # Required field
+ min_length=1,
+ description=(
+ "Index version identifier that matches index_version.txt. "
+ "Used for cache invalidation and version tracking."
+ ),
+ )
+
+ source_files: list[source_file_entry_class] = Field( # type: ignore[valid-type]
+ default_factory=list,
+ description=(
+ "List of source files used to build the index. "
+ "Each entry includes path, hash, size, and timestamp."
+ ),
+ )
+
+ # =====================================================================
+ # Validators
+ # =====================================================================
+
+ @field_validator("schema_version", mode="before")
+ @classmethod
+ def _normalize_schema_version(cls, value: object) -> str:
+ """Normalize the schema_version field.
+
+ Args:
+ ----
+ value: The input value to normalize.
+
+ Returns:
+ -------
+ Stripped schema version string.
+
+ Raises:
+ ------
+ ValueError: If value is None or empty.
+
+ """
+ if value is None:
+ msg = "schema_version cannot be None"
+ raise ValueError(msg)
+
+ version = str(value).strip()
+
+ if not version:
+ msg = "schema_version cannot be empty"
+ raise ValueError(msg)
+
+ return version
+
+ @field_validator("index_version", mode="before")
+ @classmethod
+ def _normalize_index_version(cls, value: object) -> str:
+ """Normalize the index_version field.
+
+ Args:
+ ----
+ value: The input value to normalize.
+
+ Returns:
+ -------
+ Stripped index version string.
+
+ Raises:
+ ------
+ ValueError: If value is None or empty.
+
+ """
+ if value is None:
+ msg = "index_version cannot be None"
+ raise ValueError(msg)
+
+ version = str(value).strip()
+
+ if not version:
+ msg = "index_version cannot be empty"
+ raise ValueError(msg)
+
+ return version
+
+ # =====================================================================
+ # Instance Methods
+ # =====================================================================
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert the manifest to a JSON-serializable dictionary.
+
+ This method produces a dictionary suitable for JSON serialization.
+ Datetime fields are converted to ISO 8601 format strings.
+
+ Returns:
+ -------
+ dict[str, Any]
+ Dictionary with all fields, datetimes as ISO 8601 strings.
+
+ Example:
+ -------
+ >>> manifest.to_dict()
+ {
+ "schema_version": "1.0.0",
+ "created_at": "2024-01-15T10:30:00Z",
+ "index_version": "2024.01.15.001",
+ "source_files": [...]
+ }
+
+ """
+ # Build list of source file dictionaries for JSON serialization
+ # Using Any type since source_files contains dynamically typed instances
+ source_file_dicts: list[dict[str, Any]] = []
+ for file_entry in self.source_files:
+ # Access attributes directly - type safety ensured by Pydantic
+ entry: Any = file_entry
+ source_file_dicts.append(
+ {
+ "path": entry.path,
+ "sha256": entry.sha256,
+ "size_bytes": entry.size_bytes,
+ "modified_at": entry.modified_at.isoformat(),
+ }
+ )
+
+ return {
+ "schema_version": self.schema_version,
+ "created_at": self.created_at.isoformat(),
+ "index_version": self.index_version,
+ "source_files": source_file_dicts,
+ }
+
+ @property
+ def total_source_size_bytes(self) -> int:
+ """Calculate the total size of all source files.
+
+ Returns
+ -------
+ Total size in bytes of all source files in the manifest.
+
+ """
+ # Sum size_bytes from all source files
+ # Using Any type since source_files contains dynamically typed instances
+ total: int = 0
+ for file_entry in self.source_files:
+ entry: Any = file_entry
+ total += int(entry.size_bytes)
+ return total
+
+ @property
+ def source_file_count(self) -> int:
+ """Get the number of source files in the manifest.
+
+ Returns
+ -------
+ Number of source files.
+
+ """
+ return len(self.source_files)
+
+ return _SourceManifest
+
+
+# =============================================================================
+# Model Class Cache
+# =============================================================================
+# These module-level variables cache the lazily-created Pydantic model classes.
+# The first access creates the class; subsequent accesses return the cached class.
+# =============================================================================
+
+_source_file_entry_model: type | None = None
+_source_manifest_model: type | None = None
+
+
+def _get_source_file_entry() -> type:
+ """Get or create the SourceFileEntry model class.
+
+ This function implements the lazy loading pattern. The Pydantic
+ model class is created on first call and cached for subsequent calls.
+
+ Returns
+ -------
+ type: The SourceFileEntry Pydantic model class.
+
+ """
+ global _source_file_entry_model # noqa: PLW0603
+ if _source_file_entry_model is None:
+ _source_file_entry_model = _create_source_file_entry_model()
+ return _source_file_entry_model
+
+
+def _get_source_manifest() -> type:
+ """Get or create the SourceManifest model class.
+
+ This function implements the lazy loading pattern. The Pydantic
+ model class is created on first call and cached for subsequent calls.
+
+ Returns
+ -------
+ type: The SourceManifest Pydantic model class.
+
+ """
+ global _source_manifest_model # noqa: PLW0603
+ if _source_manifest_model is None:
+ # First get the SourceFileEntry class (creates it if needed)
+ source_file_entry_class = _get_source_file_entry()
+ _source_manifest_model = _create_source_manifest_model(source_file_entry_class)
+ return _source_manifest_model
+
+
+# =============================================================================
+# Public Model Classes (Lazy Proxies)
+# =============================================================================
+# These classes act as proxies that defer model creation until first use.
+# This enables lazy loading while maintaining the appearance of regular classes.
+# =============================================================================
+
+
+class SourceFileEntry:
+ """Model for a single source file entry in the manifest.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Each SourceFileEntry represents one source file that was used
+ to build the RAG index. It includes the file path, content hash,
+ size, and modification timestamp for verification and debugging.
+
+ Attributes:
+ ----------
+ path : str
+ Relative path to the source file from the project root.
+ sha256 : str
+ SHA256 hash of the file content (64 hex characters).
+ size_bytes : int
+ File size in bytes.
+ modified_at : datetime
+ Last modification timestamp of the source file.
+
+ Example:
+ -------
+ >>> from datetime import datetime, UTC
+ >>> entry = SourceFileEntry(
+ ... path="data/raw/ashrae_55.pdf",
+ ... sha256="abc123def456...",
+ ... size_bytes=1024000,
+ ... modified_at=datetime.now(UTC),
+ ... )
+ >>> entry.path
+ 'data/raw/ashrae_55.pdf'
+
+ """
+
+ # Type stubs for mypy
+ path: str
+ sha256: str
+ size_bytes: int
+ modified_at: datetime
+
+ def __new__(cls, **kwargs: object) -> SourceFileEntry:
+ """Create a new SourceFileEntry instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model. Required fields:
+ - path: str
+ - sha256: str
+ - size_bytes: int
+ - modified_at: datetime
+
+ Returns:
+ -------
+ SourceFileEntry: A SourceFileEntry Pydantic model instance.
+
+ Raises:
+ ------
+ pydantic.ValidationError: If required fields are missing or
+ field values fail validation.
+
+ """
+ model_class = _get_source_file_entry()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> SourceFileEntry:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate. Can be a dict with the required fields
+ or another object with matching attributes.
+
+ Returns:
+ -------
+ SourceFileEntry: Validated SourceFileEntry instance.
+
+ Raises:
+ ------
+ pydantic.ValidationError: If validation fails.
+
+ """
+ model_class = _get_source_file_entry()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+ @classmethod
+ def model_json_schema(cls) -> dict[str, Any]:
+ """Get the JSON schema for the SourceFileEntry model.
+
+ Returns
+ -------
+ dict[str, Any]: JSON schema dictionary.
+
+ """
+ model_class = _get_source_file_entry()
+ return model_class.model_json_schema() # type: ignore[attr-defined, no-any-return]
+
+
+class SourceManifest:
+ """Model for the source_manifest.json file.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ The SourceManifest tracks metadata about the source files and
+ build process for the RAG index. It enables:
+ - Schema version validation for compatibility checking
+ - Build timestamp tracking for debugging
+ - Index version matching with index_version.txt
+ - Source file tracking for change detection
+
+ Attributes:
+ ----------
+ schema_version : str
+ Schema version of this manifest (e.g., "1.0.0").
+ created_at : datetime
+ When this manifest was generated.
+ index_version : str
+ Index version identifier.
+ source_files : list[SourceFileEntry]
+ List of source files used to build the index.
+
+ Example:
+ -------
+ >>> from datetime import datetime, UTC
+ >>> manifest = SourceManifest(
+ ... schema_version="1.0.0",
+ ... created_at=datetime.now(UTC),
+ ... index_version="2024.01.15.001",
+ ... source_files=[],
+ ... )
+ >>> manifest.schema_version
+ '1.0.0'
+
+ """
+
+ # Type stubs for mypy
+ schema_version: str
+ created_at: datetime
+ index_version: str
+ source_files: list[SourceFileEntry]
+
+ def __new__(cls, **kwargs: object) -> SourceManifest:
+ """Create a new SourceManifest instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model. Required fields:
+ - schema_version: str
+ - created_at: datetime
+ - index_version: str
+ Optional fields:
+ - source_files: list[SourceFileEntry] (defaults to [])
+
+ Returns:
+ -------
+ SourceManifest: A SourceManifest Pydantic model instance.
+
+ Raises:
+ ------
+ pydantic.ValidationError: If required fields are missing or
+ field values fail validation.
+
+ """
+ model_class = _get_source_manifest()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> SourceManifest:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate. Can be a dict with the required fields
+ or another object with matching attributes.
+
+ Returns:
+ -------
+ SourceManifest: Validated SourceManifest instance.
+
+ Raises:
+ ------
+ pydantic.ValidationError: If validation fails.
+
+ """
+ model_class = _get_source_manifest()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+ @classmethod
+ def model_json_schema(cls) -> dict[str, Any]:
+ """Get the JSON schema for the SourceManifest model.
+
+ Returns
+ -------
+ dict[str, Any]: JSON schema dictionary.
+
+ """
+ model_class = _get_source_manifest()
+ return model_class.model_json_schema() # type: ignore[attr-defined, no-any-return]
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert the manifest to a JSON-serializable dictionary.
+
+ This method is a proxy to the underlying Pydantic model's to_dict().
+ It produces a dictionary suitable for JSON serialization with
+ datetime fields converted to ISO 8601 format strings.
+
+ Returns:
+ -------
+ dict[str, Any]: Dictionary with all fields serialized.
+
+ Note:
+ ----
+ This is a stub method. The actual implementation is on
+ the dynamically created Pydantic model class.
+
+ """
+ # This should never be called directly on the proxy class
+ # Instances are actually the underlying Pydantic model
+ msg = "Call to_dict() on actual instance"
+ raise NotImplementedError(msg) # pragma: no cover
+
+ @property
+ def total_source_size_bytes(self) -> int:
+ """Calculate the total size of all source files.
+
+ Returns
+ -------
+ Total size in bytes.
+
+ """
+ # This is a stub - actual implementation on the model
+ raise NotImplementedError # pragma: no cover
+
+ @property
+ def source_file_count(self) -> int:
+ """Get the number of source files.
+
+ Returns
+ -------
+ Number of source files.
+
+ """
+ # This is a stub - actual implementation on the model
+ raise NotImplementedError # pragma: no cover
diff --git a/src/rag_chatbot/api/middleware/__init__.py b/src/rag_chatbot/api/middleware/__init__.py
index a719da86a3415d0ab090074eb050045fd03f8b88..22474c86a934497ef8ffe7e3e270e2f9ca3f3c04 100644
--- a/src/rag_chatbot/api/middleware/__init__.py
+++ b/src/rag_chatbot/api/middleware/__init__.py
@@ -20,12 +20,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
- from .error_handler import ErrorHandlerMiddleware
+ from .error_handler import APIError, ErrorHandlerMiddleware
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["ErrorHandlerMiddleware"]
+__all__: list[str] = ["ErrorHandlerMiddleware", "APIError"]
def __getattr__(name: str) -> object:
@@ -51,5 +51,9 @@ def __getattr__(name: str) -> object:
from .error_handler import ErrorHandlerMiddleware
return ErrorHandlerMiddleware
+ if name == "APIError":
+ from .error_handler import APIError
+
+ return APIError
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
diff --git a/src/rag_chatbot/api/middleware/error_handler.py b/src/rag_chatbot/api/middleware/error_handler.py
index 76b26a06b533ed145077a7ff6c99f6710a012224..5e938b84b1668792b7182d972aa58eb44d4a36f1 100644
--- a/src/rag_chatbot/api/middleware/error_handler.py
+++ b/src/rag_chatbot/api/middleware/error_handler.py
@@ -7,26 +7,130 @@ and formatting errors in API responses. The middleware:
- Logs errors for debugging
- Hides sensitive information in production
+The middleware implements the ASGI interface directly for maximum
+flexibility and minimal dependencies. It catches three categories
+of errors:
+ 1. APIError: Custom application errors with structured information
+ 2. ValidationError: Pydantic validation failures (422 responses)
+ 3. Generic Exception: Unexpected errors (500 responses)
+
Lazy Loading:
- Starlette middleware base is loaded on first use.
+ Pydantic and datetime are loaded on first use to minimize import
+ overhead at module load time.
+
+Environment Behavior:
+ - In development/staging: Full error details in responses
+ - In production: Generic messages, details logged only
-Note:
-----
- This is a placeholder that will be fully implemented in Step 4.1.
+Example:
+-------
+ >>> from fastapi import FastAPI
+ >>> from rag_chatbot.api.middleware import ErrorHandlerMiddleware
+ >>> app = FastAPI()
+ >>> app.add_middleware(ErrorHandlerMiddleware, is_production=True)
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
+import json
+import logging
+import os
+import traceback
+from datetime import UTC
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
- pass # Future type imports will go here
+ from collections.abc import Awaitable, Callable
+ from datetime import datetime
+
+ # ASGI type aliases for documentation purposes
+ ASGIScope = dict[str, Any]
+ ASGIReceive = Callable[[], Awaitable[dict[str, Any]]]
+ ASGISend = Callable[[dict[str, Any]], Awaitable[None]]
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["ErrorHandlerMiddleware", "APIError"]
+__all__: list[str] = [
+ "ErrorHandlerMiddleware",
+ "APIError",
+ "ErrorResponse",
+ "ValidationErrorResponse",
+]
+
+# =============================================================================
+# Module-Level Logger
+# =============================================================================
+# Create a logger for this module. The logger name follows Python conventions
+# using the module's __name__ to enable hierarchical logging configuration.
+# =============================================================================
+_logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# HTTP Status Code Constants
+# =============================================================================
+# These constants define the boundaries for HTTP error status codes.
+# Using named constants improves readability and satisfies linting rules.
+# =============================================================================
+
+HTTP_CLIENT_ERROR_MIN: int = 400
+HTTP_SERVER_ERROR_MIN: int = 500
+HTTP_ERROR_MAX: int = 600
+
+
+# =============================================================================
+# Error Codes
+# =============================================================================
+# Centralized error codes for consistent error identification across the API.
+# These codes are machine-readable and stable across versions.
+# =============================================================================
+
+
+class ErrorCode:
+ """Centralized error codes for API responses.
+
+ These codes provide machine-readable identifiers for different
+ error categories. Clients can use these codes for:
+ - Conditional error handling
+ - Localized error messages
+ - Analytics and monitoring
+
+ Attributes
+ ----------
+ VALIDATION_ERROR : str
+ Request validation failed (422).
+ INTERNAL_ERROR : str
+ Unexpected server error (500).
+ NOT_FOUND : str
+ Resource not found (404).
+ RATE_LIMITED : str
+ Too many requests (429).
+ SERVICE_UNAVAILABLE : str
+ Upstream service unavailable (503).
+ BAD_REQUEST : str
+ Malformed request (400).
+ UNAUTHORIZED : str
+ Authentication required (401).
+ FORBIDDEN : str
+ Permission denied (403).
+
+ """
+
+ VALIDATION_ERROR: str = "VALIDATION_ERROR"
+ INTERNAL_ERROR: str = "INTERNAL_ERROR"
+ NOT_FOUND: str = "NOT_FOUND"
+ RATE_LIMITED: str = "RATE_LIMITED"
+ SERVICE_UNAVAILABLE: str = "SERVICE_UNAVAILABLE"
+ BAD_REQUEST: str = "BAD_REQUEST"
+ UNAUTHORIZED: str = "UNAUTHORIZED"
+ FORBIDDEN: str = "FORBIDDEN"
+
+
+# =============================================================================
+# Custom Exceptions
+# =============================================================================
class APIError(Exception):
@@ -39,24 +143,42 @@ class APIError(Exception):
- Error code for client handling
- Optional details
+ The exception is designed to be caught by ErrorHandlerMiddleware
+ and converted to a structured JSON response. Using APIError allows
+ application code to raise meaningful errors that are automatically
+ formatted for the client.
+
Attributes:
----------
- status_code: HTTP status code for the error.
- message: Human-readable error message.
- error_code: Machine-readable error code.
- details: Optional additional error details.
+ status_code : int
+ HTTP status code for the error response.
+ message : str
+ Human-readable error message.
+ error_code : str
+ Machine-readable error code for client handling.
+ details : dict[str, Any] | None
+ Optional dictionary with additional error details.
Example:
-------
>>> raise APIError(
... status_code=400,
- ... message="Invalid query",
- ... error_code="INVALID_QUERY"
+ ... message="Invalid query parameter",
+ ... error_code="INVALID_QUERY",
+ ... details={"field": "query", "issue": "cannot be empty"},
+ ... )
+
+ >>> raise APIError(
+ ... status_code=404,
+ ... message="Document not found",
+ ... error_code="NOT_FOUND",
... )
Note:
----
- This class will be fully implemented in Phase 4 (Step 4.1).
+ - The message should be user-friendly and safe to display.
+ - The error_code should be stable across versions.
+ - Sensitive details should NOT be included in the details dict.
"""
@@ -65,28 +187,572 @@ class APIError(Exception):
status_code: int,
message: str,
error_code: str,
- details: dict[str, str] | None = None,
+ details: dict[str, Any] | None = None,
) -> None:
"""Initialize an API error.
Args:
----
- status_code: HTTP status code.
- message: Human-readable error message.
- error_code: Machine-readable error code.
- details: Optional additional details.
+ status_code : int
+ HTTP status code (e.g., 400, 404, 500).
+ message : str
+ Human-readable error message.
+ error_code : str
+ Machine-readable error code (e.g., "VALIDATION_ERROR").
+ details : dict[str, Any] | None
+ Optional dictionary with additional error details.
Raises:
------
- NotImplementedError: APIError will be implemented in Step 4.1.
+ ValueError
+ If status_code is not a valid HTTP error code.
+ ValueError
+ If message or error_code is empty.
"""
+ # Validate status_code is a valid HTTP error code (4xx or 5xx)
+ if not (HTTP_CLIENT_ERROR_MIN <= status_code < HTTP_ERROR_MAX):
+ msg = f"status_code must be between 400 and 599, got {status_code}"
+ raise ValueError(msg)
+
+ # Validate message is not empty
+ if not message or not message.strip():
+ msg = "message cannot be empty"
+ raise ValueError(msg)
+
+ # Validate error_code is not empty
+ if not error_code or not error_code.strip():
+ msg = "error_code cannot be empty"
+ raise ValueError(msg)
+
+ # Call parent Exception constructor with the message
super().__init__(message)
+
+ # Store instance attributes
self.status_code = status_code
- self.message = message
- self.error_code = error_code
+ self.message = message.strip()
+ self.error_code = error_code.strip()
self.details = details
- raise NotImplementedError("APIError will be implemented in Step 4.1")
+
+ def __repr__(self) -> str:
+ """Return a string representation of the error.
+
+ Returns
+ -------
+ str
+ String representation including all error attributes.
+
+ """
+ return (
+ f"APIError(status_code={self.status_code}, "
+ f"message={self.message!r}, "
+ f"error_code={self.error_code!r}, "
+ f"details={self.details!r})"
+ )
+
+
+# =============================================================================
+# Response Models
+# =============================================================================
+
+
+def _get_error_response_class() -> type:
+ """Lazily create the ErrorResponse Pydantic model.
+
+ This function defers the import of Pydantic until the ErrorResponse
+ class is actually needed, reducing module load time.
+
+ Returns
+ -------
+ type
+ The ErrorResponse Pydantic model class.
+
+ """
+ from datetime import datetime as dt
+
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class ErrorResponse(BaseModel):
+ """Structured error response model for API errors.
+
+ This model defines the standard format for all error responses
+ returned by the API. It ensures consistent error formatting
+ across all endpoints and error types.
+
+ The model is designed for JSON serialization and includes
+ all fields that clients need for error handling and display.
+
+ Attributes:
+ ----------
+ error_code : str
+ Machine-readable error code for client handling.
+ Examples: "VALIDATION_ERROR", "NOT_FOUND", "INTERNAL_ERROR"
+ message : str
+ Human-readable error message suitable for display.
+ Should be clear, concise, and actionable when possible.
+ details : dict | None
+ Optional dictionary with additional error context.
+ May be None or omitted in production for security.
+ Examples: {"field": "query", "issue": "required"}
+ timestamp : str
+ ISO 8601 formatted timestamp of when the error occurred.
+ Always in UTC timezone with 'Z' suffix.
+ Example: "2024-01-15T10:30:00Z"
+
+ Example:
+ -------
+ >>> response = ErrorResponse(
+ ... error_code="VALIDATION_ERROR",
+ ... message="Request validation failed",
+ ... details={"field": "query", "issue": "required"},
+ ... timestamp="2024-01-15T10:30:00Z",
+ ... )
+ >>> response.model_dump_json()
+ '{"error_code":"VALIDATION_ERROR","message":...'
+
+ """
+
+ # ---------------------------------------------------------------------
+ # Model Configuration
+ # ---------------------------------------------------------------------
+ model_config = ConfigDict(
+ # Forbid extra fields to ensure response consistency
+ extra="forbid",
+ # Make the model immutable for thread-safety
+ frozen=True,
+ # Allow population by field name
+ populate_by_name=True,
+ # Enable JSON schema generation with examples
+ json_schema_extra={
+ "examples": [
+ {
+ "error_code": "VALIDATION_ERROR",
+ "message": "Request validation failed",
+ "details": {"field": "query", "issue": "required"},
+ "timestamp": "2024-01-15T10:30:00Z",
+ },
+ {
+ "error_code": "INTERNAL_ERROR",
+ "message": "An unexpected error occurred",
+ "details": None,
+ "timestamp": "2024-01-15T10:30:00Z",
+ },
+ ]
+ },
+ )
+
+ # ---------------------------------------------------------------------
+ # Fields
+ # ---------------------------------------------------------------------
+
+ error_code: str = Field(
+ ..., # Required field
+ min_length=1,
+ description="Machine-readable error code for client handling",
+ examples=["VALIDATION_ERROR", "NOT_FOUND", "INTERNAL_ERROR"],
+ )
+
+ message: str = Field(
+ ..., # Required field
+ min_length=1,
+ description="Human-readable error message",
+ examples=[
+ "Request validation failed",
+ "Resource not found",
+ "An unexpected error occurred",
+ ],
+ )
+
+ details: dict[str, Any] | None = Field(
+ default=None,
+ description="Optional dictionary with additional error context",
+ examples=[{"field": "query", "issue": "required"}, None],
+ )
+
+ timestamp: str = Field(
+ ..., # Required field
+ description="ISO 8601 formatted timestamp (UTC)",
+ examples=["2024-01-15T10:30:00Z", "2024-06-01T14:00:00Z"],
+ )
+
+ @classmethod
+ def create(
+ cls,
+ error_code: str,
+ message: str,
+ details: dict[str, Any] | None = None,
+ timestamp: dt | None = None,
+ ) -> ErrorResponse:
+ """Create an ErrorResponse with automatic timestamp.
+
+ Factory method that generates the current UTC timestamp
+ automatically if not provided.
+
+ Args:
+ ----
+ error_code : str
+ Machine-readable error code.
+ message : str
+ Human-readable error message.
+ details : dict[str, Any] | None
+ Optional additional error context.
+ timestamp : dt | None
+ Optional timestamp (defaults to now UTC).
+
+ Returns:
+ -------
+ ErrorResponse
+ New ErrorResponse instance.
+
+ """
+ if timestamp is None:
+ timestamp = dt.now(UTC)
+
+ # Format timestamp as ISO 8601 with UTC 'Z' suffix
+ timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ return cls(
+ error_code=error_code,
+ message=message,
+ details=details,
+ timestamp=timestamp_str,
+ )
+
+ return ErrorResponse
+
+
+def _get_validation_error_response_class() -> type:
+ """Lazily create the ValidationErrorResponse Pydantic model.
+
+ This function defers the import of Pydantic until the model
+ is actually needed.
+
+ Returns
+ -------
+ type
+ The ValidationErrorResponse Pydantic model class.
+
+ """
+ from datetime import datetime as dt
+
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class ValidationErrorDetail(BaseModel):
+ """Detail about a single validation error.
+
+ Attributes
+ ----------
+ loc : list[str | int]
+ Location of the error in the request body.
+ Examples: ["body", "query"], ["body", "items", 0, "name"]
+ msg : str
+ Error message describing what went wrong.
+ type : str
+ Error type identifier from Pydantic.
+ Examples: "missing", "string_type", "value_error"
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ )
+
+ loc: list[str | int] = Field(
+ ...,
+ description="Location of the error in the request",
+ )
+
+ msg: str = Field(
+ ...,
+ description="Error message describing what went wrong",
+ )
+
+ type: str = Field(
+ ...,
+ description="Error type identifier",
+ )
+
+ class ValidationErrorResponse(BaseModel):
+ """Structured response for validation errors (422).
+
+ This model provides detailed information about validation
+ failures, including the specific fields that failed and
+ why. It follows the FastAPI/Pydantic validation error format.
+
+ Attributes:
+ ----------
+ error_code : str
+ Always "VALIDATION_ERROR" for this response type.
+ message : str
+ Summary message about the validation failure.
+ detail : list[ValidationErrorDetail]
+ List of individual validation errors with locations.
+ timestamp : str
+ ISO 8601 formatted timestamp (UTC).
+
+ Example:
+ -------
+ >>> response = ValidationErrorResponse(
+ ... error_code="VALIDATION_ERROR",
+ ... message="Request validation failed",
+ ... detail=[
+ ... ValidationErrorDetail(
+ ... loc=["body", "query"],
+ ... msg="field required",
+ ... type="missing",
+ ... )
+ ... ],
+ ... timestamp="2024-01-15T10:30:00Z",
+ ... )
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "error_code": "VALIDATION_ERROR",
+ "message": "Request validation failed",
+ "detail": [
+ {
+ "loc": ["body", "query"],
+ "msg": "field required",
+ "type": "missing",
+ }
+ ],
+ "timestamp": "2024-01-15T10:30:00Z",
+ }
+ ]
+ },
+ )
+
+ error_code: str = Field(
+ default="VALIDATION_ERROR",
+ description="Error code (always VALIDATION_ERROR for this type)",
+ )
+
+ message: str = Field(
+ default="Request validation failed",
+ description="Summary message about the validation failure",
+ )
+
+ detail: list[ValidationErrorDetail] = Field(
+ default_factory=list,
+ description="List of individual validation errors",
+ )
+
+ timestamp: str = Field(
+ ...,
+ description="ISO 8601 formatted timestamp (UTC)",
+ )
+
+ @classmethod
+ def from_pydantic_errors(
+ cls,
+ errors: list[dict[str, Any]],
+ timestamp: dt | None = None,
+ ) -> ValidationErrorResponse:
+ """Create from Pydantic validation errors.
+
+ Factory method that converts raw Pydantic validation errors
+ into a structured ValidationErrorResponse.
+
+ Args:
+ ----
+ errors : list[dict[str, Any]]
+ List of error dictionaries from Pydantic.
+ timestamp : dt | None
+ Optional timestamp (defaults to now UTC).
+
+ Returns:
+ -------
+ ValidationErrorResponse
+ New ValidationErrorResponse instance.
+
+ """
+ if timestamp is None:
+ timestamp = dt.now(UTC)
+
+ timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Convert Pydantic errors to ValidationErrorDetail
+ details = []
+ for error in errors:
+ details.append(
+ ValidationErrorDetail(
+ loc=error.get("loc", []),
+ msg=error.get("msg", "Unknown error"),
+ type=error.get("type", "unknown"),
+ )
+ )
+
+ return cls(
+ error_code="VALIDATION_ERROR",
+ message="Request validation failed",
+ detail=details,
+ timestamp=timestamp_str,
+ )
+
+ return ValidationErrorResponse
+
+
+# =============================================================================
+# Lazy-loaded Response Model Classes
+# =============================================================================
+# These module-level variables hold the lazily-created Pydantic model classes.
+# They are populated on first use to avoid importing Pydantic at module load.
+# =============================================================================
+
+_ErrorResponseClass: type | None = None
+_ValidationErrorResponseClass: type | None = None
+
+
+def _get_error_response() -> type:
+ """Get the ErrorResponse class, creating it lazily if needed."""
+ global _ErrorResponseClass # noqa: PLW0603
+ if _ErrorResponseClass is None:
+ _ErrorResponseClass = _get_error_response_class()
+ return _ErrorResponseClass
+
+
+def _get_validation_error_response() -> type:
+ """Get the ValidationErrorResponse class, creating it lazily if needed."""
+ global _ValidationErrorResponseClass # noqa: PLW0603
+ if _ValidationErrorResponseClass is None:
+ _ValidationErrorResponseClass = _get_validation_error_response_class()
+ return _ValidationErrorResponseClass
+
+
+# =============================================================================
+# Public Response Model Accessors
+# =============================================================================
+# These classes act as lazy proxies to the actual Pydantic models.
+# They enable type hints while deferring Pydantic import.
+# =============================================================================
+
+
+class _ErrorResponseProxy:
+ """Lazy proxy for the ErrorResponse Pydantic model.
+
+ This class defers importing Pydantic until the model is actually used.
+ See _get_error_response_class() for the actual model implementation.
+
+ The proxy implements __new__ to return actual Pydantic model instances,
+ and provides static factory methods that delegate to the lazy-loaded class.
+
+ Usage:
+ >>> ErrorResponse.create(
+ ... error_code="NOT_FOUND",
+ ... message="Resource not found",
+ ... )
+
+ """
+
+ # Type stubs for Pydantic model methods
+ error_code: str
+ message: str
+ details: dict[str, Any] | None
+ timestamp: str
+
+ def model_dump(self) -> dict[str, Any]:
+ """Dump the model to a dictionary (delegated to actual instance)."""
+ raise NotImplementedError # pragma: no cover
+
+ def __new__(
+ cls,
+ *args: Any, # noqa: ANN401
+ **kwargs: Any, # noqa: ANN401
+ ) -> Any: # noqa: ANN401
+ """Create an instance of the actual ErrorResponse model."""
+ actual_class = _get_error_response()
+ return actual_class(*args, **kwargs)
+
+ @staticmethod
+ def create(
+ error_code: str,
+ message: str,
+ details: dict[str, Any] | None = None,
+ timestamp: datetime | None = None,
+ ) -> Any: # noqa: ANN401
+ """Create an ErrorResponse with automatic timestamp.
+
+ See _get_error_response_class() for full documentation.
+ """
+ actual_class = _get_error_response()
+ # Access create method dynamically since the class is created lazily
+ # Using getattr because actual_class is a dynamically created type
+ create_fn: Any = getattr(actual_class, "create") # noqa: B009
+ return create_fn(
+ error_code=error_code,
+ message=message,
+ details=details,
+ timestamp=timestamp,
+ )
+
+
+# Public alias for the ErrorResponse proxy class
+ErrorResponse = _ErrorResponseProxy
+
+
+class _ValidationErrorResponseProxy:
+ """Lazy proxy for the ValidationErrorResponse Pydantic model.
+
+ This class defers importing Pydantic until the model is actually used.
+ See _get_validation_error_response_class() for the actual model.
+
+ Usage:
+ >>> ValidationErrorResponse.from_pydantic_errors(errors=[...])
+
+ """
+
+ # Type stubs for Pydantic model methods
+ error_code: str
+ message: str
+ detail: list[Any]
+ timestamp: str
+
+ def model_dump(self) -> dict[str, Any]:
+ """Dump the model to a dictionary (delegated to actual instance)."""
+ raise NotImplementedError # pragma: no cover
+
+ def __new__(
+ cls,
+ *args: Any, # noqa: ANN401
+ **kwargs: Any, # noqa: ANN401
+ ) -> Any: # noqa: ANN401
+ """Create an instance of the actual ValidationErrorResponse model."""
+ actual_class = _get_validation_error_response()
+ return actual_class(*args, **kwargs)
+
+ @staticmethod
+ def from_pydantic_errors(
+ errors: list[dict[str, Any]],
+ timestamp: datetime | None = None,
+ ) -> Any: # noqa: ANN401
+ """Create from Pydantic validation errors.
+
+ See _get_validation_error_response_class() for full documentation.
+ """
+ actual_class = _get_validation_error_response()
+ # Access from_pydantic_errors method dynamically
+ # Using getattr because actual_class is a dynamically created type
+ factory_fn: Any = getattr(actual_class, "from_pydantic_errors") # noqa: B009
+ return factory_fn(
+ errors=errors,
+ timestamp=timestamp,
+ )
+
+
+# Public alias for the ValidationErrorResponse proxy class
+ValidationErrorResponse = _ValidationErrorResponseProxy
+
+
+# =============================================================================
+# ASGI Middleware
+# =============================================================================
class ErrorHandlerMiddleware:
@@ -102,53 +768,361 @@ class ErrorHandlerMiddleware:
The middleware also logs errors for debugging while hiding
sensitive information in production responses.
+ The middleware implements the raw ASGI interface for maximum
+ flexibility and minimal dependencies. It wraps the application
+ and intercepts exceptions during request processing.
+
+ Attributes:
+ ----------
+ _app : object
+ The wrapped ASGI application.
+ _is_production : bool
+ Whether running in production mode.
+ In production, error details are hidden from responses.
+
Example:
-------
>>> from fastapi import FastAPI
>>> app = FastAPI()
>>> app.add_middleware(ErrorHandlerMiddleware)
+ >>> # Or with explicit production mode:
+ >>> app.add_middleware(ErrorHandlerMiddleware, is_production=True)
Note:
----
- This class will be fully implemented in Phase 4 (Step 4.1).
+ The middleware determines production mode from:
+ 1. The is_production parameter (if provided)
+ 2. The ENVIRONMENT environment variable (if set to "production")
+ 3. Defaults to False (development mode)
"""
- def __init__(self, app: object) -> None:
+ def __init__(
+ self,
+ app: object,
+ is_production: bool | None = None,
+ ) -> None:
"""Initialize the error handler middleware.
Args:
----
- app: The ASGI application to wrap.
+ app : object
+ The ASGI application to wrap.
+ is_production : bool | None
+ Whether to run in production mode.
+ In production, error details are hidden from responses.
+ If None, reads from ENVIRONMENT env var.
- Raises:
- ------
- NotImplementedError: ErrorHandlerMiddleware will be implemented
- in Step 4.1.
+ Example:
+ -------
+ >>> middleware = ErrorHandlerMiddleware(app)
+ >>> # Or with explicit production mode:
+ >>> middleware = ErrorHandlerMiddleware(app, is_production=True)
"""
self._app = app
- raise NotImplementedError(
- "ErrorHandlerMiddleware will be implemented in Step 4.1"
+
+ # Determine production mode from parameter or environment
+ if is_production is not None:
+ self._is_production = is_production
+ else:
+ # Read from ENVIRONMENT variable, default to development
+ env = os.environ.get("ENVIRONMENT", "development").lower()
+ self._is_production = env == "production"
+
+ _logger.debug(
+ "ErrorHandlerMiddleware initialized (production=%s)",
+ self._is_production,
)
async def __call__(
self,
- scope: dict[str, object],
+ scope: dict[str, Any],
receive: object,
send: object,
) -> None:
"""Process a request through the middleware.
+ This method wraps the request processing and catches any
+ exceptions that occur. Caught exceptions are converted to
+ structured JSON error responses.
+
Args:
----
- scope: ASGI scope dictionary.
- receive: ASGI receive callable.
- send: ASGI send callable.
+ scope : dict[str, Any]
+ ASGI scope dictionary containing request metadata.
+ receive : object
+ ASGI receive callable for reading request body.
+ send : object
+ ASGI send callable for sending response.
- Raises:
- ------
- NotImplementedError: Method will be implemented in Step 4.1.
+ Note:
+ ----
+ - Only HTTP requests are processed for error handling.
+ - WebSocket and other connection types pass through unchanged.
+ - Errors during response sending may not be fully handled.
"""
- raise NotImplementedError("__call__() will be implemented in Step 4.1")
+ # Only handle HTTP requests
+ if scope["type"] != "http":
+ await self._app(scope, receive, send) # type: ignore[operator]
+ return
+
+ # Track whether response has started (headers sent)
+ response_started = False
+
+ async def send_wrapper(message: dict[str, Any]) -> None:
+ """Track when response headers are sent."""
+ nonlocal response_started
+ if message["type"] == "http.response.start":
+ response_started = True
+ await send(message) # type: ignore[operator]
+
+ try:
+ # Process the request through the wrapped application
+ await self._app(scope, receive, send_wrapper) # type: ignore[operator]
+
+ except APIError as exc:
+ # Handle custom API errors
+ await self._handle_api_error(exc, send, response_started)
+
+ except Exception as exc:
+ # Check if it's a Pydantic ValidationError
+ # We check by class name to avoid importing Pydantic eagerly
+ if type(exc).__name__ == "ValidationError":
+ await self._handle_validation_error(exc, send, response_started)
+ else:
+ # Handle unexpected errors (500)
+ await self._handle_internal_error(exc, send, response_started)
+
+ async def _handle_api_error(
+ self,
+ exc: APIError,
+ send: object,
+ response_started: bool,
+ ) -> None:
+ """Handle a custom APIError exception.
+
+ Convert the APIError to a structured JSON response with
+ the appropriate status code and error details.
+
+ Args:
+ ----
+ exc : APIError
+ The APIError exception that was raised.
+ send : object
+ ASGI send callable.
+ response_started : bool
+ Whether response headers were already sent.
+
+ """
+ # Log the error with appropriate level based on status code
+ if exc.status_code >= HTTP_SERVER_ERROR_MIN:
+ _logger.error(
+ "API error [%s]: %s (code=%s, details=%s)",
+ exc.status_code,
+ exc.message,
+ exc.error_code,
+ exc.details,
+ )
+ else:
+ _logger.warning(
+ "API error [%s]: %s (code=%s)",
+ exc.status_code,
+ exc.message,
+ exc.error_code,
+ )
+
+ # If response already started, we cannot send error response
+ if response_started:
+ _logger.error("Cannot send error response - headers already sent")
+ return
+
+ # Build error response
+ # In production, hide details for 5xx errors
+ details = exc.details
+ if self._is_production and exc.status_code >= HTTP_SERVER_ERROR_MIN:
+ details = None
+
+ response = ErrorResponse.create(
+ error_code=exc.error_code,
+ message=exc.message,
+ details=details,
+ )
+
+ await self._send_json_response(
+ send,
+ status_code=exc.status_code,
+ body=response.model_dump(),
+ )
+
+ async def _handle_validation_error(
+ self,
+ exc: Exception,
+ send: object,
+ response_started: bool,
+ ) -> None:
+ """Handle a Pydantic ValidationError exception.
+
+ Convert validation errors to a 422 response with detailed
+ field-level error information.
+
+ Args:
+ ----
+ exc : Exception
+ The ValidationError exception that was raised.
+ send : object
+ ASGI send callable.
+ response_started : bool
+ Whether response headers were already sent.
+
+ """
+ _logger.warning("Validation error: %s", str(exc))
+
+ if response_started:
+ _logger.error(
+ "Cannot send validation error response - headers already sent"
+ )
+ return
+
+ # Extract errors from Pydantic ValidationError
+ # The errors() method returns a list of error dicts
+ try:
+ errors = exc.errors() # type: ignore[attr-defined]
+ except AttributeError:
+ # Fallback if errors() method not available
+ errors = [{"loc": [], "msg": str(exc), "type": "validation_error"}]
+
+ # In production, simplify the error details
+ if self._is_production:
+ # Provide a generic message without field details
+ response_body = {
+ "error_code": ErrorCode.VALIDATION_ERROR,
+ "message": "Request validation failed",
+ "details": None,
+ "timestamp": self._get_timestamp(),
+ }
+ else:
+ # In development, provide full validation details
+ response = ValidationErrorResponse.from_pydantic_errors(errors)
+ response_body = response.model_dump()
+
+ await self._send_json_response(
+ send,
+ status_code=422,
+ body=response_body,
+ )
+
+ async def _handle_internal_error(
+ self,
+ exc: Exception,
+ send: object,
+ response_started: bool,
+ ) -> None:
+ """Handle an unexpected internal error.
+
+ Convert unexpected exceptions to a 500 response with
+ appropriate error details (hidden in production).
+
+ Args:
+ ----
+ exc : Exception
+ The exception that was raised.
+ send : object
+ ASGI send callable.
+ response_started : bool
+ Whether response headers were already sent.
+
+ """
+ # Always log the full traceback for internal errors
+ _logger.error(
+ "Internal server error: %s\n%s",
+ str(exc),
+ traceback.format_exc(),
+ )
+
+ if response_started:
+ _logger.error("Cannot send internal error response - headers already sent")
+ return
+
+ # Build error response
+ # In production, hide all details
+ if self._is_production:
+ message = "An unexpected error occurred"
+ details = None
+ else:
+ message = f"Internal error: {type(exc).__name__}: {exc}"
+ details = {
+ "exception_type": type(exc).__name__,
+ "exception_message": str(exc),
+ "traceback": traceback.format_exc().split("\n"),
+ }
+
+ response = ErrorResponse.create(
+ error_code=ErrorCode.INTERNAL_ERROR,
+ message=message,
+ details=details,
+ )
+
+ await self._send_json_response(
+ send,
+ status_code=HTTP_SERVER_ERROR_MIN,
+ body=response.model_dump(),
+ )
+
+ async def _send_json_response(
+ self,
+ send: object,
+ status_code: int,
+ body: dict[str, Any],
+ ) -> None:
+ """Send a JSON response through ASGI.
+
+ Helper method to construct and send an HTTP response with
+ JSON content type and body.
+
+ Args:
+ ----
+ send : object
+ ASGI send callable.
+ status_code : int
+ HTTP status code for the response.
+ body : dict[str, Any]
+ Dictionary to serialize as JSON body.
+
+ """
+ # Serialize body to JSON bytes
+ body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
+
+ # Send response headers
+ await send( # type: ignore[operator]
+ {
+ "type": "http.response.start",
+ "status": status_code,
+ "headers": [
+ [b"content-type", b"application/json; charset=utf-8"],
+ [b"content-length", str(len(body_bytes)).encode("utf-8")],
+ ],
+ }
+ )
+
+ # Send response body
+ await send( # type: ignore[operator]
+ {
+ "type": "http.response.body",
+ "body": body_bytes,
+ }
+ )
+
+ def _get_timestamp(self) -> str:
+ """Get the current UTC timestamp in ISO 8601 format.
+
+ Returns
+ -------
+ str
+ ISO 8601 formatted timestamp string with 'Z' suffix.
+
+ """
+ from datetime import datetime as dt
+
+ return dt.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/src/rag_chatbot/api/resources.py b/src/rag_chatbot/api/resources.py
new file mode 100644
index 0000000000000000000000000000000000000000..89aa6b0e617cc2fac8ca00875196c02f7d0820a4
--- /dev/null
+++ b/src/rag_chatbot/api/resources.py
@@ -0,0 +1,838 @@
+"""Resource manager for lazy-loaded application resources.
+
+This module provides a ResourceManager class that handles lazy initialization
+and caching of heavy application resources like the retriever and settings.
+Resources are NOT loaded on application startup - they are loaded on the first
+request that needs them, then cached for subsequent requests.
+
+Key Features:
+ - **Deferred Loading**: Resources load on first request, not startup
+ - **Caching**: Once loaded, resources are cached for fast access
+ - **Concurrent Load Protection**: Prevents multiple simultaneous loads
+ - **Artifact Download**: Downloads RAG artifacts from HuggingFace if needed
+ - **Metrics Tracking**: Tracks startup time and memory usage
+ - **Ready State**: Exposes ready state for health check endpoints
+
+Performance Targets:
+ - Cold start (first request): < 30 seconds
+ - Warm start (subsequent requests): < 5ms (cached)
+ - Memory usage: Tracked and logged after loading
+
+Architecture:
+ The ResourceManager is a singleton accessed via get_resource_manager().
+ It stores:
+ - Settings: Application configuration from environment
+ - Retriever: HybridRetriever wrapped with optional reranking
+
+ The loading pipeline includes artifact download from HuggingFace:
+ 1. Load Settings from environment variables
+ 2. Download/verify artifacts via ArtifactDownloader (Step 7.7)
+ 3. Create retriever using factory function
+ 4. Record metrics (duration, memory)
+
+Lazy Loading Strategy:
+ All heavy dependencies (torch, faiss, sentence-transformers) are imported
+ inside methods rather than at module level. This ensures:
+ - Fast module import time
+ - Minimal memory usage until resources are needed
+ - Clean separation between import and initialization
+
+Usage:
+ The ResourceManager is used by route handlers to access shared resources:
+
+ >>> from rag_chatbot.api.resources import get_resource_manager
+ >>>
+ >>> async def query_handler(query: str):
+ ... manager = get_resource_manager()
+ ... await manager.ensure_loaded() # Lazy load if needed
+ ... retriever = manager.get_retriever()
+ ... results = retriever.retrieve(query)
+ ... return results
+
+Integration with Health Checks:
+ The /health/ready endpoint uses is_ready() to report whether the
+ application is ready to serve requests:
+
+ >>> manager = get_resource_manager()
+ >>> if manager.is_ready():
+ ... return {"ready": True}
+ ... else:
+ ... return {"ready": False}
+
+See Also
+--------
+ Settings : Configuration module
+ Application configuration (src/rag_chatbot/config/settings.py)
+ RetrieverWithReranker : Retriever wrapper
+ Retriever wrapper (src/rag_chatbot/retrieval/factory.py)
+ ArtifactDownloader : Artifact downloader
+ Downloads artifacts from HuggingFace (artifact_downloader.py)
+ _lifespan : Lifecycle manager
+ Application lifecycle (src/rag_chatbot/api/main.py)
+
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from typing import TYPE_CHECKING
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# Heavy dependencies are NOT imported at runtime to ensure fast module loading.
+# =============================================================================
+
+if TYPE_CHECKING:
+ from rag_chatbot.config.settings import Settings
+ from rag_chatbot.retrieval.factory import RetrieverWithReranker
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = ["ResourceManager", "get_resource_manager"]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Constants
+# =============================================================================
+# Threshold for warning about slow cold start (30 seconds in milliseconds)
+_COLD_START_WARNING_THRESHOLD_MS: int = 30000
+
+# =============================================================================
+# Module-level Singleton
+# =============================================================================
+# The ResourceManager is a singleton to ensure shared state across the
+# application. This variable holds the singleton instance.
+# =============================================================================
+_resource_manager: ResourceManager | None = None
+
+
+# =============================================================================
+# ResourceManager Class
+# =============================================================================
+
+
+class ResourceManager:
+ """Manager for lazy-loaded application resources.
+
+ This class handles the lifecycle of heavy application resources like the
+ retriever and settings. Resources are loaded lazily on first request and
+ cached for subsequent access.
+
+ Design Principles:
+ 1. **Lazy Loading**: Resources load on first access, not initialization
+ 2. **Thread-Safe**: Uses asyncio.Lock to prevent concurrent loads
+ 3. **Metrics Tracking**: Records load time and memory usage
+ 4. **Ready State**: Tracks whether resources are loaded for health checks
+
+ Resource Lifecycle:
+ 1. ResourceManager is instantiated (empty - no resources loaded)
+ 2. First request calls ensure_loaded()
+ 3. Resources are loaded and cached (settings, retriever)
+ 4. Subsequent requests use cached resources (fast path)
+ 5. Application shutdown calls shutdown() for cleanup
+
+ Attributes:
+ ----------
+ _retriever : RetrieverWithReranker | None
+ The cached retriever instance. None until ensure_loaded() completes.
+
+ _settings : Settings | None
+ The cached settings instance. None until ensure_loaded() completes.
+
+ _loaded : bool
+ Whether resources have been successfully loaded.
+
+ _loading : bool
+ Whether a load operation is currently in progress.
+ Used to prevent concurrent loads.
+
+ _load_lock : asyncio.Lock
+ Lock to ensure only one coroutine loads resources at a time.
+
+ _load_start_time : float | None
+ Timestamp when loading started (time.perf_counter()).
+ Used to calculate load duration.
+
+ _load_duration_ms : int | None
+ Time taken to load resources in milliseconds.
+ Logged for monitoring cold start performance.
+
+ _memory_mb : float | None
+ Process memory usage after loading in megabytes.
+ Logged for monitoring resource consumption.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> await manager.ensure_loaded()
+ >>> retriever = manager.get_retriever()
+ >>> results = retriever.retrieve("What is PMV?")
+
+ Note:
+ ----
+ This class should not be instantiated directly. Use get_resource_manager()
+ to get the singleton instance.
+
+ """
+
+ # =========================================================================
+ # Initialization
+ # =========================================================================
+
+ def __init__(self) -> None:
+ """Initialize the ResourceManager with empty state.
+
+ Creates a new ResourceManager with no resources loaded. Resources are
+ loaded lazily when ensure_loaded() is called.
+
+ The constructor does NOT import any heavy dependencies. All imports
+ of torch, faiss, sentence-transformers, etc. happen inside methods
+ when resources are actually loaded.
+
+ Note:
+ ----
+ This constructor should only be called by get_resource_manager().
+ Direct instantiation is discouraged to maintain singleton pattern.
+
+ """
+ # =====================================================================
+ # Resource Cache (initially empty)
+ # =====================================================================
+ # These are populated by ensure_loaded() on first request.
+ # Using None as sentinel for "not yet loaded" state.
+ # =====================================================================
+ self._retriever: RetrieverWithReranker | None = None
+ self._settings: Settings | None = None
+
+ # =====================================================================
+ # Loading State
+ # =====================================================================
+ # Tracks whether resources are loaded and prevents concurrent loads.
+ # =====================================================================
+ self._loaded: bool = False
+ self._loading: bool = False
+ self._load_lock: asyncio.Lock = asyncio.Lock()
+
+ # =====================================================================
+ # Metrics (populated after loading)
+ # =====================================================================
+ # These track performance metrics for monitoring and alerting.
+ # =====================================================================
+ self._load_start_time: float | None = None
+ self._load_duration_ms: int | None = None
+ self._memory_mb: float | None = None
+
+ logger.debug("ResourceManager initialized (resources not yet loaded)")
+
+ # =========================================================================
+ # Private Methods
+ # =========================================================================
+
+ def _get_memory_usage_mb(self) -> float:
+ """Get current process memory usage in megabytes.
+
+ Uses psutil to get the Resident Set Size (RSS) of the current process.
+ RSS represents the actual physical memory used by the process.
+
+ Returns:
+ -------
+ Current process memory usage in MB (megabytes).
+
+ Note:
+ ----
+ This requires psutil to be installed. If psutil is not available,
+ returns 0.0 and logs a warning.
+
+ Memory is measured after resource loading completes to track the
+ impact of loading FAISS indexes, embeddings, and models.
+
+ Example:
+ -------
+ >>> memory = self._get_memory_usage_mb()
+ >>> print(f"Memory usage: {memory:.2f} MB")
+ Memory usage: 512.34 MB
+
+ """
+ try:
+ import psutil # type: ignore[import-untyped]
+
+ process = psutil.Process()
+ # memory_info().rss returns bytes, convert to MB
+ memory_bytes: int = process.memory_info().rss
+ return float(memory_bytes) / (1024 * 1024)
+ except ImportError:
+ logger.warning(
+ "psutil not installed - cannot measure memory usage. "
+ "Install with: pip install psutil"
+ )
+ return 0.0
+ except Exception:
+ # Catch any other errors (permissions, etc.) without crashing
+ logger.warning("Failed to get memory usage", exc_info=True)
+ return 0.0
+
+ async def _load_resources(self) -> None:
+ """Load all application resources.
+
+ This is the core loading method that initializes all heavy dependencies.
+ It is called by ensure_loaded() when resources need to be loaded.
+
+ Loading Steps:
+ 1. Load Settings from environment variables
+ 2. Download/verify artifacts from HuggingFace via ArtifactDownloader
+ 3. Create retriever using factory function
+ 4. Record metrics (duration, memory)
+
+ The method imports heavy dependencies inside the function to ensure
+ they are only loaded when actually needed, not at module import time.
+
+ Raises:
+ ------
+ RuntimeError: If loading fails for any reason, including:
+ - Failed to download artifacts from HuggingFace
+ - Failed to create retriever from artifacts
+
+ Note:
+ ----
+ This method assumes it is called while holding _load_lock.
+ Do not call directly - use ensure_loaded() instead.
+
+ The retriever itself performs lazy loading of its components
+ (FAISS index, BM25 index, encoder model). The first retrieve()
+ call will trigger additional loading.
+
+ """
+ logger.info("Loading application resources...")
+
+ # =====================================================================
+ # Step 1: Load Settings
+ # =====================================================================
+ # Import Settings lazily to avoid loading pydantic_settings at module
+ # import time. Settings reads from environment variables.
+ # =====================================================================
+ logger.debug("Loading settings from environment")
+
+ from rag_chatbot.config.settings import Settings
+
+ self._settings = Settings()
+
+ logger.debug(
+ "Settings loaded: use_hybrid=%s, use_reranker=%s, top_k=%d",
+ self._settings.use_hybrid,
+ self._settings.use_reranker,
+ self._settings.top_k,
+ )
+
+ # =====================================================================
+ # Step 2: Download/verify artifacts from HuggingFace (Step 7.7)
+ # =====================================================================
+ # The ArtifactDownloader handles:
+ # - Version-based cache invalidation (compares local vs remote version)
+ # - Cache hit: Uses existing artifacts (fast path, ~1 second)
+ # - Cache miss: Downloads all artifacts from HuggingFace (~10-30 seconds)
+ # - Force refresh: Re-downloads if FORCE_ARTIFACT_REFRESH=true
+ # - Retry logic with exponential backoff for transient failures
+ #
+ # The downloader returns the path to the cache directory containing:
+ # - chunks.parquet: Document chunks with metadata
+ # - embeddings.parquet: Embedding vectors for semantic search
+ # - faiss_index.bin: FAISS index for dense retrieval
+ # - bm25_index.pkl: BM25 index for sparse/lexical retrieval
+ # - index_version.txt: Version identifier for cache invalidation
+ # =====================================================================
+ logger.debug(
+ "Ensuring artifacts are available (repo=%s, force_refresh=%s)",
+ self._settings.hf_index_repo,
+ self._settings.force_artifact_refresh,
+ )
+
+ # Lazy import to avoid loading huggingface_hub at module import time
+ from rag_chatbot.api.artifact_downloader import (
+ ArtifactDownloader,
+ ArtifactDownloadError,
+ )
+
+ # Track artifact download time separately for monitoring
+ artifact_start_time = time.perf_counter()
+
+ try:
+ downloader = ArtifactDownloader(self._settings)
+ artifact_path = await downloader.ensure_artifacts_available()
+ except ArtifactDownloadError as e:
+ # Log the full exception with traceback for operators
+ logger.exception(
+ "Failed to download artifacts from HuggingFace (repo=%s)",
+ self._settings.hf_index_repo,
+ )
+ # Re-raise as RuntimeError with helpful message for operators
+ msg = (
+ f"Failed to download RAG artifacts from HuggingFace: {e}. "
+ f"Check HF_TOKEN is valid, repo '{self._settings.hf_index_repo}' "
+ "exists, and network connectivity to HuggingFace."
+ )
+ raise RuntimeError(msg) from e
+
+ artifact_elapsed_ms = int((time.perf_counter() - artifact_start_time) * 1000)
+ logger.info(
+ "Artifact download/verification completed in %d ms, path: %s",
+ artifact_elapsed_ms,
+ artifact_path,
+ )
+
+ # =====================================================================
+ # Step 2.5: Validate Dataset Freshness (Step 9.5)
+ # =====================================================================
+ # The FreshnessValidator checks that downloaded artifacts are:
+ # - Schema version compatible with this server code
+ # - Complete and consistent (manifest matches version file)
+ #
+ # If validation fails, the server refuses to start with a clear
+ # error message indicating what needs to be fixed.
+ # =====================================================================
+ logger.debug("Validating dataset freshness...")
+
+ # Lazy import to avoid loading at module import time
+ from rag_chatbot.api.freshness import (
+ FreshnessValidationError,
+ FreshnessValidator,
+ )
+
+ freshness_validator = FreshnessValidator(artifact_path, self._settings)
+
+ try:
+ manifest = freshness_validator.validate()
+ # Log the index version on boot (acceptance criteria)
+ logger.info(
+ "Dataset validated: index_version=%s, schema_version=%s",
+ manifest.index_version,
+ manifest.schema_version,
+ )
+ except FreshnessValidationError as e:
+ # Fail fast with clear error if validation fails (acceptance criteria)
+ logger.exception(
+ "Dataset freshness validation failed - server cannot start"
+ )
+ msg = (
+ f"Dataset freshness validation failed: {e}. "
+ f"The server cannot start with incompatible or corrupt artifacts. "
+ f"Repository: {self._settings.hf_index_repo}"
+ )
+ raise RuntimeError(msg) from e
+
+ # =====================================================================
+ # Step 3: Create Retriever
+ # =====================================================================
+ # Import the factory function lazily. This triggers loading of
+ # HybridRetriever, DenseRetriever, and related modules.
+ #
+ # The retriever factory:
+ # - Creates HybridRetriever or DenseRetriever based on use_hybrid
+ # - Wraps with RetrieverWithReranker if use_reranker is enabled
+ # - Configures top_k from settings
+ #
+ # Note: The retriever loads FAISS/BM25 indexes from disk, but the
+ # encoder model is lazy-loaded on first retrieve() call.
+ # =====================================================================
+ logger.debug("Creating retriever from factory")
+
+ from rag_chatbot.retrieval.factory import get_default_retriever
+
+ self._retriever = get_default_retriever(
+ index_path=artifact_path,
+ settings=self._settings,
+ )
+
+ logger.debug(
+ "Retriever created: type=%s, use_reranker=%s",
+ type(self._retriever.retriever).__name__,
+ self._retriever.use_reranker,
+ )
+
+ # =========================================================================
+ # Public Methods
+ # =========================================================================
+
+ async def ensure_loaded(self) -> None:
+ """Ensure all resources are loaded, loading them if necessary.
+
+ This is the main entry point for resource loading. It implements
+ lazy loading with the following behavior:
+
+ 1. If already loaded: Return immediately (fast path, < 1ms)
+ 2. If another coroutine is loading: Wait for it to complete
+ 3. If not loaded: Acquire lock and load resources
+
+ The method uses an asyncio.Lock to ensure that only one coroutine
+ performs the actual loading. Other concurrent calls will wait for
+ the loading to complete rather than loading redundantly.
+
+ Performance:
+ - Warm path (already loaded): < 1ms
+ - Cold path (first load): 10-30 seconds depending on index size
+ - Concurrent path (waiting): Same as cold path + minor wait overhead
+
+ Raises:
+ ------
+ RuntimeError: If resource loading fails. The error is logged and
+ re-raised. The manager remains in unloaded state for retry.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> await manager.ensure_loaded() # May take 10-30s on cold start
+ >>> await manager.ensure_loaded() # Returns immediately (cached)
+
+ Note:
+ ----
+ This method is idempotent - calling it multiple times is safe.
+ After the first successful load, subsequent calls return immediately.
+
+ If loading fails, _loaded remains False and the next call will
+ attempt to load again. This provides automatic retry behavior.
+
+ """
+ # =====================================================================
+ # Fast Path: Already Loaded
+ # =====================================================================
+ # Check _loaded without lock for fast path. This is safe because
+ # _loaded only transitions from False to True, never back.
+ # =====================================================================
+ if self._loaded:
+ logger.debug("Resources already loaded (fast path)")
+ return
+
+ # =====================================================================
+ # Acquire Lock for Loading
+ # =====================================================================
+ # Use asyncio.Lock to ensure only one coroutine loads at a time.
+ # Other coroutines wait here until the lock is released.
+ # =====================================================================
+ async with self._load_lock:
+ # =================================================================
+ # Double-Check After Acquiring Lock
+ # =================================================================
+ # Another coroutine may have completed loading while we waited.
+ # Check again inside the lock to avoid redundant loading.
+ # =================================================================
+ if self._loaded:
+ logger.debug("Resources loaded by another coroutine (waited)")
+ return
+
+ # =================================================================
+ # Perform Loading
+ # =================================================================
+ # We hold the lock, so we are the only one loading.
+ # Set _loading flag for observability (not strictly necessary
+ # with the lock, but useful for debugging/monitoring).
+ # =================================================================
+ self._loading = True
+ self._load_start_time = time.perf_counter()
+
+ try:
+ # Load all resources
+ await self._load_resources()
+
+ # =============================================================
+ # Record Metrics
+ # =============================================================
+ # Calculate load duration and memory usage for monitoring.
+ # These are logged and exposed via get_load_stats().
+ # =============================================================
+ load_end_time = time.perf_counter()
+ self._load_duration_ms = int(
+ (load_end_time - self._load_start_time) * 1000
+ )
+ self._memory_mb = self._get_memory_usage_mb()
+
+ # Log the metrics with appropriate severity
+ # Cold start > 30s is concerning, log as warning
+ if self._load_duration_ms > _COLD_START_WARNING_THRESHOLD_MS:
+ logger.warning(
+ "Resources loaded in %d ms (exceeds 30s target), "
+ "memory: %.2f MB",
+ self._load_duration_ms,
+ self._memory_mb,
+ )
+ else:
+ logger.info(
+ "Resources loaded in %d ms, memory: %.2f MB",
+ self._load_duration_ms,
+ self._memory_mb,
+ )
+
+ # Mark as loaded (success)
+ self._loaded = True
+
+ except Exception as e:
+ # =============================================================
+ # Handle Loading Failure
+ # =============================================================
+ # Log the error and re-raise. Keep _loaded as False so that
+ # subsequent calls will retry loading.
+ # =============================================================
+ elapsed_ms = int((time.perf_counter() - self._load_start_time) * 1000)
+ logger.exception(
+ "Failed to load resources after %d ms",
+ elapsed_ms,
+ )
+ msg = f"Failed to load resources: {e}"
+ raise RuntimeError(msg) from e
+
+ finally:
+ # Always clear the loading flag
+ self._loading = False
+
+ def is_ready(self) -> bool:
+ """Check if resources are loaded and ready for requests.
+
+ This method is used by health check endpoints to report whether the
+ application is ready to serve requests. An application is ready when:
+ - Resources have been loaded successfully
+ - Retriever is available for queries
+
+ Returns:
+ -------
+ True if resources are loaded and ready, False otherwise.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> manager.is_ready()
+ False # Not yet loaded
+ >>> await manager.ensure_loaded()
+ >>> manager.is_ready()
+ True # Now ready
+
+ Note:
+ ----
+ This method does NOT trigger loading. Use ensure_loaded() to
+ trigger lazy loading. This method only checks current state.
+
+ The ready state is used by:
+ - /health/ready endpoint for Kubernetes readiness probes
+ - Load balancers to determine if instance can serve traffic
+
+ """
+ return self._loaded
+
+ def get_retriever(self) -> RetrieverWithReranker:
+ """Get the cached retriever instance.
+
+ Returns the RetrieverWithReranker that was loaded by ensure_loaded().
+ This is used by query handlers to retrieve relevant documents.
+
+ Returns:
+ -------
+ The cached RetrieverWithReranker instance.
+
+ Raises:
+ ------
+ RuntimeError: If called before ensure_loaded() completes.
+ Always call ensure_loaded() first.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> await manager.ensure_loaded()
+ >>> retriever = manager.get_retriever()
+ >>> results = retriever.retrieve("What is PMV?", top_k=5)
+
+ Note:
+ ----
+ This method does NOT trigger loading. It returns the cached
+ instance or raises an error if not loaded.
+
+ The retriever performs additional lazy loading on first retrieve()
+ call (encoder model). This is handled internally by the retriever.
+
+ """
+ if self._retriever is None:
+ msg = (
+ "Retriever not loaded. Call ensure_loaded() first. "
+ "This error indicates a programming bug - ensure_loaded() "
+ "should be called before accessing resources."
+ )
+ raise RuntimeError(msg)
+
+ return self._retriever
+
+ def get_settings(self) -> Settings:
+ """Get the cached settings instance.
+
+ Returns the Settings that were loaded by ensure_loaded().
+ This provides access to application configuration.
+
+ Returns:
+ -------
+ The cached Settings instance.
+
+ Raises:
+ ------
+ RuntimeError: If called before ensure_loaded() completes.
+ Always call ensure_loaded() first.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> await manager.ensure_loaded()
+ >>> settings = manager.get_settings()
+ >>> print(f"Using top_k={settings.top_k}")
+
+ Note:
+ ----
+ This method does NOT trigger loading. It returns the cached
+ instance or raises an error if not loaded.
+
+ For settings access before loading, create a new Settings()
+ instance directly (but prefer using the cached one when available).
+
+ """
+ if self._settings is None:
+ msg = (
+ "Settings not loaded. Call ensure_loaded() first. "
+ "This error indicates a programming bug - ensure_loaded() "
+ "should be called before accessing resources."
+ )
+ raise RuntimeError(msg)
+
+ return self._settings
+
+ def get_load_stats(self) -> dict[str, int | float | bool | None]:
+ """Get loading statistics for monitoring and debugging.
+
+ Returns a dictionary with metrics about resource loading:
+ - loaded: Whether resources are loaded
+ - loading: Whether loading is in progress
+ - load_duration_ms: Time taken to load (ms), None if not loaded
+ - memory_mb: Memory usage after loading (MB), None if not loaded
+
+ Returns:
+ -------
+ Dictionary with loading statistics.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> stats = manager.get_load_stats()
+ >>> # Before loading: loaded=False, loading=False
+ >>> await manager.ensure_loaded()
+ >>> stats = manager.get_load_stats()
+ >>> # After loading: loaded=True, load_duration_ms=15234
+
+ Note:
+ ----
+ This is primarily used for:
+ - Health check endpoints to report startup metrics
+ - Debugging slow startups
+ - Monitoring memory consumption
+
+ """
+ return {
+ "loaded": self._loaded,
+ "loading": self._loading,
+ "load_duration_ms": self._load_duration_ms,
+ "memory_mb": self._memory_mb,
+ }
+
+ async def shutdown(self) -> None:
+ """Clean up resources on application shutdown.
+
+ This method is called during application shutdown to release resources
+ and perform cleanup tasks:
+ - Clear cached retriever reference
+ - Clear cached settings reference
+ - Log shutdown metrics
+
+ The cleanup allows garbage collection of heavy objects (FAISS index,
+ encoder model, etc.) and ensures clean shutdown.
+
+ Note:
+ ----
+ After shutdown(), the manager can be reloaded by calling
+ ensure_loaded() again. This supports restart scenarios.
+
+ This method should be called from the application lifespan
+ context manager's shutdown phase.
+
+ Example:
+ -------
+ >>> manager = get_resource_manager()
+ >>> await manager.ensure_loaded()
+ >>> # ... serve requests ...
+ >>> await manager.shutdown() # Clean up on exit
+
+ See Also:
+ --------
+ _lifespan in src/rag_chatbot/api/main.py for integration.
+
+ """
+ logger.info("Shutting down ResourceManager...")
+
+ # Log final stats before cleanup
+ if self._loaded:
+ logger.info(
+ "Final resource stats: load_duration=%s ms, memory=%s MB",
+ self._load_duration_ms,
+ self._memory_mb,
+ )
+
+ # Clear cached resources to allow garbage collection
+ self._retriever = None
+ self._settings = None
+ self._loaded = False
+ self._loading = False
+
+ logger.info("ResourceManager shutdown complete")
+
+
+# =============================================================================
+# Singleton Accessor
+# =============================================================================
+
+
+def get_resource_manager() -> ResourceManager:
+ """Get or create the singleton ResourceManager instance.
+
+ This function provides access to the global ResourceManager singleton.
+ On first call, it creates the ResourceManager. Subsequent calls return
+ the same instance.
+
+ Returns:
+ -------
+ The singleton ResourceManager instance.
+
+ Example:
+ -------
+ >>> manager1 = get_resource_manager()
+ >>> manager2 = get_resource_manager()
+ >>> manager1 is manager2
+ True
+
+ Note:
+ ----
+ This function is thread-safe for access (single assignment).
+ The ResourceManager itself uses asyncio.Lock for thread-safe loading.
+
+ The singleton pattern ensures:
+ - Shared state across route handlers
+ - Resources loaded only once
+ - Consistent metrics tracking
+
+ """
+ global _resource_manager # noqa: PLW0603
+
+ if _resource_manager is None:
+ _resource_manager = ResourceManager()
+ logger.debug("Created ResourceManager singleton")
+
+ return _resource_manager
diff --git a/src/rag_chatbot/api/routes/health.py b/src/rag_chatbot/api/routes/health.py
index cb6e14e30f08da41b7156fdc02f5ad95fba6fc95..56d83186f7d2b74a45db6f0a173b7f5113f89e8a 100644
--- a/src/rag_chatbot/api/routes/health.py
+++ b/src/rag_chatbot/api/routes/health.py
@@ -7,50 +7,352 @@ application status. The endpoints report:
- Component status (retriever, LLM providers)
Lazy Loading:
- FastAPI is loaded on first use.
+ FastAPI and Pydantic are loaded on first use via factory functions
+ to avoid import overhead at module load time.
-Note:
-----
- This is a placeholder that will be fully implemented in Step 4.1.
+Example:
+-------
+ >>> from rag_chatbot.api.routes.health import router
+ >>> # Router provides /health and /readiness endpoints
"""
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from fastapi import APIRouter
+ from starlette.responses import Response
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["router"]
+__all__: list[str] = ["router", "HealthResponse", "ReadinessResponse"]
+
+# =============================================================================
+# Constants
+# =============================================================================
+_APP_VERSION: str = "0.1.0"
+
+# =============================================================================
+# Lazy Loading State
+# =============================================================================
+_HealthResponseClass: type[object] | None = None
+_ReadinessResponseClass: type[object] | None = None
+_router_instance: APIRouter | None = None
+
+
+def _create_health_response_class() -> type[object]:
+ """Create the HealthResponse Pydantic model with lazy-loaded imports.
+
+ This function defers the import of pydantic until the HealthResponse
+ class is actually used, avoiding import overhead at module load.
+
+ Returns
+ -------
+ The HealthResponse Pydantic model class.
+
+ """
+ from pydantic import BaseModel, Field
+
+ class HealthResponse(BaseModel):
+ """Response model for health check endpoint.
+
+ This Pydantic model represents the health status of the application,
+ including overall status, version, and individual component statuses.
+
+ Attributes:
+ ----------
+ status: Overall health status of the application.
+ Must be one of: 'healthy', 'degraded', 'unhealthy'.
+ version: Application version string (e.g., '0.1.0').
+ components: Dictionary mapping component names to their status.
+ Each component status is one of: 'healthy', 'degraded', 'unhealthy'.
+
+ Example:
+ -------
+ >>> response = HealthResponse(
+ ... status="healthy",
+ ... version="0.1.0",
+ ... components={"api": "healthy"}
+ ... )
+ >>> response.model_dump()
+ {'status': 'healthy', 'version': '0.1.0', 'components': {'api': 'healthy'}}
+
+ """
+
+ status: str = Field(
+ ...,
+ description=(
+ "Overall health status of the application. "
+ "One of: 'healthy', 'degraded', 'unhealthy'."
+ ),
+ )
+
+ version: str = Field(
+ ...,
+ description="Application version string (e.g., '0.1.0').",
+ )
+
+ components: dict[str, str] = Field(
+ ...,
+ description=(
+ "Status of individual components. "
+ "Maps component name to status string."
+ ),
+ )
+
+ return HealthResponse
+
+
+def _create_readiness_response_class() -> type[object]:
+ """Create the ReadinessResponse Pydantic model with lazy-loaded imports.
+
+ This function defers the import of pydantic until the ReadinessResponse
+ class is actually used, avoiding import overhead at module load.
+
+ Returns
+ -------
+ The ReadinessResponse Pydantic model class.
+
+ """
+ from pydantic import BaseModel, Field
+
+ class ReadinessResponse(BaseModel):
+ """Response model for readiness check endpoint.
+
+ This Pydantic model represents the readiness status of the application,
+ indicating whether all dependencies are available and the application
+ is ready to serve requests.
+
+ Attributes:
+ ----------
+ ready: Whether the application is ready to serve requests.
+ checks: Dictionary mapping check names to their boolean results.
+ True indicates the check passed, False indicates failure.
+
+ Example:
+ -------
+ >>> response = ReadinessResponse(
+ ... ready=True,
+ ... checks={"api": True}
+ ... )
+ >>> response.model_dump()
+ {'ready': True, 'checks': {'api': True}}
+
+ """
+
+ ready: bool = Field(
+ ...,
+ description="Whether the application is ready to serve requests.",
+ )
+
+ checks: dict[str, bool] = Field(
+ ...,
+ description=(
+ "Results of individual readiness checks. "
+ "Maps check name to boolean result."
+ ),
+ )
+
+ return ReadinessResponse
+
+
+def _create_router() -> APIRouter:
+ """Create the FastAPI router with health check endpoints.
+
+ This function defers the import of FastAPI until the router is
+ actually accessed, avoiding import overhead at module load.
+
+ Returns
+ -------
+ An APIRouter instance with /health, /ready, and /readiness endpoints.
+
+ """
+ global _HealthResponseClass, _ReadinessResponseClass # noqa: PLW0603
+ from fastapi import APIRouter as FastAPIRouter
+
+ router = FastAPIRouter(tags=["Health"])
+
+ # Get response models - ensure the actual Pydantic classes are created
+ if _HealthResponseClass is None:
+ _HealthResponseClass = _create_health_response_class()
+ if _ReadinessResponseClass is None:
+ _ReadinessResponseClass = _create_readiness_response_class()
+
+ health_response_model = _HealthResponseClass
+ readiness_response_model = _ReadinessResponseClass
+
+ @router.get(
+ "/health",
+ response_model=health_response_model,
+ summary="Health check endpoint",
+ description=(
+ "Returns the current health status of the application, "
+ "including version and component statuses."
+ ),
+ )
+ async def health_check() -> HealthResponse:
+ """Check the health status of the application.
+
+ Returns the overall health status, application version, and
+ status of individual components (API, retriever, LLM providers).
+
+ The status is determined by the ResourceManager state:
+ - "healthy": All resources loaded and ready
+ - "degraded": API is up but resources not yet loaded
+
+ Returns
+ -------
+ HealthResponse with current status, version, and component statuses.
+
+ """
+ # Lazy import to maintain the lazy loading pattern
+ from rag_chatbot.api.resources import get_resource_manager
+
+ manager = get_resource_manager()
+
+ # Determine overall status based on resource state:
+ # - "healthy": All resources loaded and ready
+ # - "degraded": API is up but resources not yet loaded
+ status = "healthy" if manager.is_ready() else "degraded"
+
+ # Build component status dictionary
+ components = {
+ "api": "healthy", # API is responding (we're here)
+ "resources": "healthy" if manager.is_ready() else "degraded",
+ }
-# Placeholder for the router - will be initialized in Step 4.1
-# Using None with type annotation to satisfy type checker
-router: Any = None # Will be APIRouter instance
+ return HealthResponse(
+ status=status,
+ version=_APP_VERSION,
+ components=components,
+ )
+
+ @router.get(
+ "/ready",
+ summary="Simple readiness probe endpoint",
+ description=(
+ "Returns 200 if application is ready to serve requests, "
+ "503 if resources are still loading. Used by Kubernetes "
+ "readiness probes and load balancers."
+ ),
+ )
+ async def ready_check() -> Response:
+ """Check if the application is ready to serve requests.
+
+ This is a simple readiness probe endpoint designed for Kubernetes
+ and load balancers. It returns:
+ - 200 with {"ready": true, "status": "ok"} when resources are loaded
+ - 503 with {"ready": false, "status": "loading"} when not ready
+
+ The 503 status code signals to load balancers that this instance
+ should not receive traffic until resources are loaded.
+
+ Returns
+ -------
+ JSONResponse with ready status and appropriate HTTP status code.
+
+ """
+ # Lazy import to maintain the lazy loading pattern
+ from starlette.responses import JSONResponse
+
+ from rag_chatbot.api.resources import get_resource_manager
+
+ manager = get_resource_manager()
+
+ if manager.is_ready():
+ # Resources loaded - ready to serve traffic
+ return JSONResponse(
+ content={"ready": True, "status": "ok"},
+ status_code=200,
+ )
+ else:
+ # Resources not loaded - reject traffic with 503
+ return JSONResponse(
+ content={"ready": False, "status": "loading"},
+ status_code=503,
+ )
+
+ @router.get(
+ "/readiness",
+ response_model=readiness_response_model,
+ summary="Readiness check endpoint",
+ description=(
+ "Returns whether the application is ready to serve requests, "
+ "including individual readiness checks for each dependency."
+ ),
+ )
+ async def readiness_check() -> ReadinessResponse:
+ """Check if the application is ready to serve requests.
+
+ Performs readiness checks on all dependencies (retriever, LLM providers)
+ and returns whether the application is ready to handle requests.
+
+ This endpoint provides more detailed information than /ready,
+ including individual check results for each component.
+
+ Returns
+ -------
+ ReadinessResponse indicating overall readiness and individual check results.
+
+ """
+ # Lazy import to maintain the lazy loading pattern
+ from rag_chatbot.api.resources import get_resource_manager
+
+ manager = get_resource_manager()
+
+ # Check if resources are loaded
+ is_ready = manager.is_ready()
+
+ # Build checks dictionary
+ checks = {
+ "api": True, # API is always available (we're responding)
+ "resources": is_ready,
+ }
+
+ return ReadinessResponse(
+ ready=is_ready,
+ checks=checks,
+ )
+
+ return router
+
+
+# =============================================================================
+# Lazy Loading Wrappers
+# =============================================================================
class HealthResponse:
"""Response model for health check endpoint.
- Attributes:
+ This is a lazy-loading wrapper that creates the actual Pydantic model
+ on first instantiation. This avoids importing pydantic at module load time.
+
+ Attributes
----------
status: Overall health status ('healthy', 'degraded', 'unhealthy').
version: Application version.
components: Status of individual components.
- Note:
- ----
- This class will be converted to a Pydantic model in Step 4.1.
+ See _create_health_response_class() for the actual Pydantic model.
"""
- def __init__(
- self,
- status: str,
- version: str,
- components: dict[str, str],
- ) -> None:
- """Initialize a health response.
+ # Type stubs for mypy
+ status: str
+ version: str
+ components: dict[str, str]
+
+ def __new__(
+ cls,
+ status: str = "",
+ version: str = "",
+ components: dict[str, str] | None = None,
+ ) -> HealthResponse:
+ """Create a new HealthResponse instance using lazy-loaded class.
Args:
----
@@ -58,43 +360,126 @@ class HealthResponse:
version: Application version.
components: Component status dictionary.
- Raises:
- ------
- NotImplementedError: HealthResponse will be implemented in Step 4.1.
+ Returns:
+ -------
+ A HealthResponse instance.
"""
- raise NotImplementedError("HealthResponse will be implemented in Step 4.1")
+ global _HealthResponseClass # noqa: PLW0603
+ if _HealthResponseClass is None:
+ _HealthResponseClass = _create_health_response_class()
+ if components is None:
+ components = {}
+ instance: object = _HealthResponseClass( # type: ignore[call-arg]
+ status=status,
+ version=version,
+ components=components,
+ )
+ return instance # type: ignore[return-value]
class ReadinessResponse:
"""Response model for readiness check endpoint.
- Attributes:
+ This is a lazy-loading wrapper that creates the actual Pydantic model
+ on first instantiation. This avoids importing pydantic at module load time.
+
+ Attributes
----------
ready: Whether the application is ready to serve requests.
checks: Results of individual readiness checks.
- Note:
- ----
- This class will be converted to a Pydantic model in Step 4.1.
+ See _create_readiness_response_class() for the actual Pydantic model.
"""
- def __init__(
- self,
- ready: bool,
- checks: dict[str, bool],
- ) -> None:
- """Initialize a readiness response.
+ # Type stubs for mypy
+ ready: bool
+ checks: dict[str, bool]
+
+ def __new__(
+ cls,
+ ready: bool = False,
+ checks: dict[str, bool] | None = None,
+ ) -> ReadinessResponse:
+ """Create a new ReadinessResponse instance using lazy-loaded class.
Args:
----
ready: Overall readiness status.
checks: Individual check results.
- Raises:
- ------
- NotImplementedError: ReadinessResponse will be implemented in Step 4.1.
+ Returns:
+ -------
+ A ReadinessResponse instance.
"""
- raise NotImplementedError("ReadinessResponse will be implemented in Step 4.1")
+ global _ReadinessResponseClass # noqa: PLW0603
+ if _ReadinessResponseClass is None:
+ _ReadinessResponseClass = _create_readiness_response_class()
+ if checks is None:
+ checks = {}
+ instance: object = _ReadinessResponseClass( # type: ignore[call-arg]
+ ready=ready,
+ checks=checks,
+ )
+ return instance # type: ignore[return-value]
+
+
+def _get_router() -> APIRouter:
+ """Get the router instance, creating it on first access.
+
+ Returns
+ -------
+ The APIRouter instance with health check endpoints.
+
+ """
+ global _router_instance # noqa: PLW0603
+ if _router_instance is None:
+ _router_instance = _create_router()
+ return _router_instance
+
+
+# =============================================================================
+# Module-level Router
+# =============================================================================
+# Use a property-like pattern via __getattr__ would be cleaner, but for
+# compatibility with FastAPI's include_router() we use a module-level object
+# that's accessed via a function call pattern or direct attribute access.
+
+
+class _RouterProxy:
+ """Proxy object that lazily creates the router on first access.
+
+ This allows the router to be accessed as a module-level attribute
+ while deferring FastAPI import until first use.
+
+ """
+
+ def __getattr__(self, name: str) -> object:
+ """Forward attribute access to the actual router.
+
+ Args:
+ ----
+ name: Attribute name to access.
+
+ Returns:
+ -------
+ The attribute from the actual router.
+
+ """
+ return getattr(_get_router(), name)
+
+ def __repr__(self) -> str:
+ """Return string representation.
+
+ Returns
+ -------
+ String representation of the router proxy.
+
+ """
+ return repr(_get_router())
+
+
+# Create the router proxy for lazy loading
+router: _RouterProxy = _RouterProxy()
diff --git a/src/rag_chatbot/api/routes/providers.py b/src/rag_chatbot/api/routes/providers.py
new file mode 100644
index 0000000000000000000000000000000000000000..07c952d3217249a1f7e4152460f07415e121c11c
--- /dev/null
+++ b/src/rag_chatbot/api/routes/providers.py
@@ -0,0 +1,921 @@
+"""Provider status route handler for monitoring LLM provider availability.
+
+This module provides the GET /providers endpoint for querying the current
+status of all LLM providers (Gemini and Groq). The endpoint returns:
+ - Overall provider availability
+ - Per-model quota status (RPM, TPM, RPD, TPD)
+ - Cooldown status and remaining time
+ - Human-readable status messages
+
+Response Caching:
+ Responses are cached in memory for 60 seconds to reduce overhead from
+ frequent status checks. The cache is thread-safe and automatically
+ expires. Cache status is included in responses.
+
+Lazy Loading:
+ FastAPI, Pydantic, and provider registries are loaded on first use
+ via factory functions to avoid import overhead at module load time.
+
+Example:
+-------
+ >>> from rag_chatbot.api.routes.providers import router
+ >>> # Router provides /providers endpoint
+ >>> # Response includes Gemini and Groq provider status
+
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from fastapi import APIRouter
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "router",
+ "ModelStatusResponse",
+ "ProviderStatusResponse",
+ "ProvidersResponse",
+]
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Cache TTL in seconds - responses are cached for 1 minute
+_CACHE_TTL_SECONDS: int = 60
+
+# Cache key for provider status response
+_CACHE_KEY: str = "provider_status"
+
+# =============================================================================
+# Lazy Loading State
+# =============================================================================
+
+# Pydantic model class references (created on first use)
+_ModelStatusResponseClass: type[object] | None = None
+_ProviderStatusResponseClass: type[object] | None = None
+_ProvidersResponseClass: type[object] | None = None
+
+# Router instance (created on first use)
+_router_instance: APIRouter | None = None
+
+# =============================================================================
+# Response Cache
+# =============================================================================
+# Thread-safe in-memory cache for provider status responses.
+# The cache stores the last response and its timestamp to avoid
+# repeatedly querying provider registries on every request.
+# =============================================================================
+
+# Lock for thread-safe cache access
+_cache_lock: threading.Lock = threading.Lock()
+
+# Cache storage: stores response object and timestamp
+# Using Any for the response since it's a dynamically created Pydantic model
+_cached_response: object | None = None
+_cached_timestamp: float | None = None
+
+
+# =============================================================================
+# Pydantic Model Factory Functions
+# =============================================================================
+
+
+def _create_model_status_response_class() -> type[object]:
+ """Create the ModelStatusResponse Pydantic model with lazy-loaded imports.
+
+ This function defers the import of pydantic until the ModelStatusResponse
+ class is actually used, avoiding import overhead at module load.
+
+ The ModelStatusResponse represents the status of a single LLM model,
+ including its availability, remaining quota, and cooldown information.
+
+ Returns
+ -------
+ The ModelStatusResponse Pydantic model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class ModelStatusResponse(BaseModel):
+ """Response model for individual LLM model status.
+
+ This Pydantic model represents the current status of a single LLM model,
+ including quota information and availability. Used within ProviderStatusResponse
+ to report on each model in a provider's registry.
+
+ Attributes:
+ ----------
+ model_name: Identifier for the model (e.g., "gemini-2.5-flash-lite").
+ is_available: Whether the model can currently accept requests.
+ False if any quota is exhausted or model is in cooldown.
+ requests_remaining_minute: Requests remaining in current minute window.
+ May be negative if quota was exceeded before check.
+ tokens_remaining_minute: Tokens remaining in current minute window.
+ May be negative if quota was exceeded before check.
+ requests_remaining_day: Requests remaining today (since midnight UTC).
+ May be negative if quota was exceeded before check.
+ tokens_remaining_day: Tokens remaining today (Groq-specific TPD limit).
+ None for Gemini models which don't have a daily token limit.
+ cooldown_seconds: Seconds until model exits cooldown after 429 error.
+ None if model is not currently in cooldown.
+ reason: Human-readable explanation of why model is unavailable.
+ None if model is available.
+
+ Example:
+ -------
+ >>> response = ModelStatusResponse(
+ ... model_name="gemini-2.5-flash",
+ ... is_available=True,
+ ... requests_remaining_minute=5,
+ ... tokens_remaining_minute=250000,
+ ... requests_remaining_day=20,
+ ... tokens_remaining_day=None,
+ ... cooldown_seconds=None,
+ ... reason=None,
+ ... )
+ >>> response.is_available
+ True
+
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ extra="forbid",
+ )
+
+ model_name: str = Field(
+ ...,
+ description="Identifier for the model (e.g., 'gemini-2.5-flash-lite')",
+ )
+
+ is_available: bool = Field(
+ ...,
+ description="Whether the model can currently accept requests",
+ )
+
+ requests_remaining_minute: int = Field(
+ ...,
+ description="Requests remaining in current minute window",
+ )
+
+ tokens_remaining_minute: int = Field(
+ ...,
+ description="Tokens remaining in current minute window",
+ )
+
+ requests_remaining_day: int = Field(
+ ...,
+ description="Requests remaining today (since midnight UTC)",
+ )
+
+ tokens_remaining_day: int | None = Field(
+ default=None,
+ description="Tokens remaining today (Groq-specific, None for Gemini)",
+ )
+
+ cooldown_seconds: int | None = Field(
+ default=None,
+ description="Seconds until model exits cooldown (None if not in cooldown)",
+ )
+
+ reason: str | None = Field(
+ default=None,
+ description="Human-readable reason why model is unavailable",
+ )
+
+ return ModelStatusResponse
+
+
+def _create_provider_status_response_class() -> type[object]:
+ """Create the ProviderStatusResponse Pydantic model with lazy-loaded imports.
+
+ This function defers the import of pydantic until the ProviderStatusResponse
+ class is actually used, avoiding import overhead at module load.
+
+ The ProviderStatusResponse aggregates status for all models in a single
+ provider (e.g., all Gemini models or all Groq models).
+
+ Returns
+ -------
+ The ProviderStatusResponse Pydantic model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ # Ensure ModelStatusResponse is created for type reference
+ global _ModelStatusResponseClass # noqa: PLW0603
+ if _ModelStatusResponseClass is None:
+ _ModelStatusResponseClass = _create_model_status_response_class()
+
+ # Get the actual class for use in type annotation
+ model_status_class = _ModelStatusResponseClass
+
+ class ProviderStatusResponse(BaseModel):
+ """Response model for a single LLM provider's status.
+
+ This Pydantic model aggregates the status of all models within
+ a single provider (Gemini or Groq). It provides an overall
+ availability flag and detailed status for each model.
+
+ Attributes:
+ ----------
+ provider: Provider identifier ("gemini" or "groq").
+ is_available: Whether any model in this provider is available.
+ True if at least one model can accept requests.
+ models: List of ModelStatusResponse objects for each model.
+ Models are ordered by priority (primary first).
+
+ Example:
+ -------
+ >>> response = ProviderStatusResponse(
+ ... provider="gemini",
+ ... is_available=True,
+ ... models=[...], # List of ModelStatusResponse
+ ... )
+ >>> response.is_available
+ True
+
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ extra="forbid",
+ )
+
+ provider: str = Field(
+ ...,
+ description="Provider identifier ('gemini' or 'groq')",
+ )
+
+ is_available: bool = Field(
+ ...,
+ description="Whether any model in this provider is available",
+ )
+
+ models: list[model_status_class] = Field( # type: ignore[valid-type]
+ ...,
+ description="Status of each model in this provider",
+ )
+
+ return ProviderStatusResponse
+
+
+def _create_providers_response_class() -> type[object]:
+ """Create the ProvidersResponse Pydantic model with lazy-loaded imports.
+
+ This function defers the import of pydantic until the ProvidersResponse
+ class is actually used, avoiding import overhead at module load.
+
+ The ProvidersResponse is the top-level response model containing status
+ for all providers and cache information.
+
+ Returns
+ -------
+ The ProvidersResponse Pydantic model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ # Ensure ProviderStatusResponse is created for type reference
+ global _ProviderStatusResponseClass # noqa: PLW0603
+ if _ProviderStatusResponseClass is None:
+ _ProviderStatusResponseClass = _create_provider_status_response_class()
+
+ # Get the actual class for use in type annotation
+ provider_status_class = _ProviderStatusResponseClass
+
+ class ProvidersResponse(BaseModel):
+ """Response model for the GET /providers endpoint.
+
+ This Pydantic model is the top-level response containing status
+ for all LLM providers (Gemini and Groq) and cache metadata.
+
+ Attributes:
+ ----------
+ providers: List of ProviderStatusResponse for each provider.
+ Currently includes Gemini and Groq providers.
+ cached: Whether this response was served from cache.
+ True if response is from the 60-second cache.
+ cache_expires_in_seconds: Seconds until cache expires.
+ None if response is fresh (not from cache).
+
+ Example:
+ -------
+ >>> response = ProvidersResponse(
+ ... providers=[...], # Gemini and Groq status
+ ... cached=True,
+ ... cache_expires_in_seconds=45,
+ ... )
+ >>> response.cached
+ True
+
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ extra="forbid",
+ )
+
+ providers: list[provider_status_class] = Field( # type: ignore[valid-type]
+ ...,
+ description="Status of all LLM providers",
+ )
+
+ cached: bool = Field(
+ ...,
+ description="Whether this response was served from cache",
+ )
+
+ cache_expires_in_seconds: int | None = Field(
+ default=None,
+ description="Seconds until cache expires (None if fresh response)",
+ )
+
+ return ProvidersResponse
+
+
+# =============================================================================
+# Cache Helper Functions
+# =============================================================================
+
+
+def _get_cached_response() -> tuple[object | None, int | None]:
+ """Retrieve cached response if still valid.
+
+ Checks the in-memory cache for a valid (non-expired) response.
+ Thread-safe via the _cache_lock.
+
+ Returns
+ -------
+ Tuple of (cached_response, expires_in_seconds).
+ If cache is invalid or expired, returns (None, None).
+
+ """
+ global _cached_response, _cached_timestamp # noqa: PLW0603
+
+ with _cache_lock:
+ # Check if cache has been set
+ if _cached_response is None or _cached_timestamp is None:
+ return None, None
+
+ # Check if cache is still valid
+ elapsed = time.time() - _cached_timestamp
+ if elapsed >= _CACHE_TTL_SECONDS:
+ # Cache expired - clear it
+ _cached_response = None
+ _cached_timestamp = None
+ return None, None
+
+ # Cache is valid - calculate time remaining
+ expires_in = int(_CACHE_TTL_SECONDS - elapsed)
+ return _cached_response, expires_in
+
+
+def _set_cached_response(response: object) -> None:
+ """Store response in cache with current timestamp.
+
+ Stores the response in the in-memory cache with the current time.
+ Thread-safe via the _cache_lock.
+
+ Args:
+ ----
+ response: The ProvidersResponse object to cache.
+
+ """
+ global _cached_response, _cached_timestamp # noqa: PLW0603
+
+ with _cache_lock:
+ _cached_response = response
+ _cached_timestamp = time.time()
+
+
+# =============================================================================
+# Provider Status Collection Functions
+# =============================================================================
+
+
+def _get_gemini_provider_status() -> object | None:
+ """Get status for Gemini provider from ProviderRegistry.
+
+ Creates a fresh ProviderRegistry instance and collects quota status
+ for all Gemini models. Returns None if Gemini API key is not configured.
+
+ Returns
+ -------
+ ProviderStatusResponse for Gemini, or None if not configured.
+
+ """
+ # Lazy import settings to get API key
+ from rag_chatbot.config import Settings
+
+ settings = Settings()
+
+ # Check if Gemini is configured
+ if not settings.gemini_api_key:
+ return None
+
+ # Lazy import registry
+ from rag_chatbot.llm.registry import ProviderRegistry
+
+ # Ensure Pydantic models are created
+ global _ModelStatusResponseClass, _ProviderStatusResponseClass # noqa: PLW0603
+ if _ModelStatusResponseClass is None:
+ _ModelStatusResponseClass = _create_model_status_response_class()
+ if _ProviderStatusResponseClass is None:
+ _ProviderStatusResponseClass = _create_provider_status_response_class()
+
+ # Create fresh registry to get current quota status
+ # The registry's QuotaManager tracks usage across the application
+ registry = ProviderRegistry(api_key=settings.gemini_api_key)
+
+ # Get status for all models
+ model_statuses = registry.get_quota_status()
+
+ # Convert to response models
+ # Gemini's ModelStatus does not have tokens_remaining_day (it's TPD-free)
+ model_responses = [
+ _ModelStatusResponseClass( # type: ignore[call-arg]
+ model_name=status.model,
+ is_available=status.is_available,
+ requests_remaining_minute=status.requests_remaining_minute,
+ tokens_remaining_minute=status.tokens_remaining_minute,
+ requests_remaining_day=status.requests_remaining_day,
+ tokens_remaining_day=None, # Gemini doesn't have TPD
+ cooldown_seconds=status.cooldown_seconds,
+ reason=status.reason,
+ )
+ for status in model_statuses
+ ]
+
+ # Create provider status
+ # Provider is available if any model is available
+ is_available = any(status.is_available for status in model_statuses)
+
+ return _ProviderStatusResponseClass( # type: ignore[call-arg]
+ provider="gemini",
+ is_available=is_available,
+ models=model_responses,
+ )
+
+
+def _get_groq_provider_status() -> object | None:
+ """Get status for Groq provider from GroqProviderRegistry.
+
+ Creates a fresh GroqProviderRegistry instance and collects quota status
+ for all Groq models. Returns None if Groq API key is not configured.
+
+ Returns
+ -------
+ ProviderStatusResponse for Groq, or None if not configured.
+
+ """
+ # Lazy import settings to get API key
+ from rag_chatbot.config import Settings
+
+ settings = Settings()
+
+ # Check if Groq is configured
+ if not settings.groq_api_key:
+ return None
+
+ # Lazy import registry
+ from rag_chatbot.llm.groq_registry import GroqProviderRegistry
+
+ # Ensure Pydantic models are created
+ global _ModelStatusResponseClass, _ProviderStatusResponseClass # noqa: PLW0603
+ if _ModelStatusResponseClass is None:
+ _ModelStatusResponseClass = _create_model_status_response_class()
+ if _ProviderStatusResponseClass is None:
+ _ProviderStatusResponseClass = _create_provider_status_response_class()
+
+ # Create fresh registry to get current quota status
+ registry = GroqProviderRegistry(api_key=settings.groq_api_key)
+
+ # Get status for all models
+ model_statuses = registry.get_quota_status()
+
+ # Convert to response models
+ # Groq's GroqModelStatus includes tokens_remaining_day (TPD limit)
+ model_responses = [
+ _ModelStatusResponseClass( # type: ignore[call-arg]
+ model_name=status.model,
+ is_available=status.is_available,
+ requests_remaining_minute=status.requests_remaining_minute,
+ tokens_remaining_minute=status.tokens_remaining_minute,
+ requests_remaining_day=status.requests_remaining_day,
+ tokens_remaining_day=status.tokens_remaining_day, # Groq-specific
+ cooldown_seconds=status.cooldown_seconds,
+ reason=status.reason,
+ )
+ for status in model_statuses
+ ]
+
+ # Create provider status
+ # Provider is available if any model is available
+ is_available = any(status.is_available for status in model_statuses)
+
+ return _ProviderStatusResponseClass( # type: ignore[call-arg]
+ provider="groq",
+ is_available=is_available,
+ models=model_responses,
+ )
+
+
+def _collect_all_provider_status() -> object:
+ """Collect status from all configured providers and build response.
+
+ Queries both Gemini and Groq registries for current quota status.
+ Providers without API keys are skipped.
+
+ Returns
+ -------
+ ProvidersResponse with status from all configured providers.
+
+ """
+ # Ensure ProvidersResponse is created
+ global _ProvidersResponseClass # noqa: PLW0603
+ if _ProvidersResponseClass is None:
+ _ProvidersResponseClass = _create_providers_response_class()
+
+ # Collect status from each provider
+ providers: list[object] = []
+
+ # Get Gemini status (if configured)
+ gemini_status = _get_gemini_provider_status()
+ if gemini_status is not None:
+ providers.append(gemini_status)
+
+ # Get Groq status (if configured)
+ groq_status = _get_groq_provider_status()
+ if groq_status is not None:
+ providers.append(groq_status)
+
+ # Build fresh response (not cached)
+ return _ProvidersResponseClass( # type: ignore[call-arg]
+ providers=providers,
+ cached=False,
+ cache_expires_in_seconds=None,
+ )
+
+
+# =============================================================================
+# Router Factory
+# =============================================================================
+
+
+def _create_router() -> APIRouter:
+ """Create the FastAPI router with provider status endpoint.
+
+ This function defers the import of FastAPI until the router is
+ actually accessed, avoiding import overhead at module load.
+
+ Returns
+ -------
+ An APIRouter instance with GET /providers endpoint.
+
+ """
+ global _ProvidersResponseClass # noqa: PLW0603
+ from fastapi import APIRouter as FastAPIRouter
+
+ router = FastAPIRouter(tags=["Providers"])
+
+ # Ensure response model is created for OpenAPI docs
+ if _ProvidersResponseClass is None:
+ _ProvidersResponseClass = _create_providers_response_class()
+
+ providers_response_model = _ProvidersResponseClass
+
+ @router.get(
+ "/providers",
+ response_model=providers_response_model,
+ summary="Get LLM provider status",
+ description=(
+ "Returns the current status of all configured LLM providers "
+ "(Gemini and Groq), including per-model quota information, "
+ "availability, and cooldown status. Responses are cached for "
+ "60 seconds to reduce overhead."
+ ),
+ )
+ async def get_providers_status() -> ProvidersResponse:
+ """Get current status of all LLM providers.
+
+ This endpoint returns the quota status for all configured LLM providers
+ (Gemini and Groq). It includes:
+ - Overall provider availability
+ - Per-model quota remaining (RPM, TPM, RPD, TPD)
+ - Cooldown status after rate limit errors
+ - Human-readable status messages
+
+ The response is cached for 60 seconds to reduce overhead from frequent
+ status checks. The cache status and expiration time are included in
+ the response.
+
+ Returns
+ -------
+ ProvidersResponse with status for all configured providers.
+
+ Example Response:
+ ----------------
+ {
+ "providers": [
+ {
+ "provider": "gemini",
+ "is_available": true,
+ "models": [
+ {
+ "model_name": "gemini-2.5-flash-lite",
+ "is_available": true,
+ "requests_remaining_minute": 10,
+ "tokens_remaining_minute": 250000,
+ "requests_remaining_day": 20,
+ "tokens_remaining_day": null,
+ "cooldown_seconds": null,
+ "reason": null
+ }
+ ]
+ }
+ ],
+ "cached": false,
+ "cache_expires_in_seconds": null
+ }
+
+ """
+ # Check cache first to avoid unnecessary registry queries
+ # The cache reduces load on the quota managers during high traffic
+ cached_response, expires_in = _get_cached_response()
+
+ if cached_response is not None and expires_in is not None:
+ # Return cached response with updated expiration time
+ # We need to create a new response object with cached=True
+ # because the original response has cached=False
+ # Use getattr to access providers since cached_response is typed as object
+ cached_providers = getattr(cached_response, "providers", [])
+ return ProvidersResponse(
+ providers=cached_providers,
+ cached=True,
+ cache_expires_in_seconds=expires_in,
+ )
+
+ # Cache miss or expired - collect fresh status from all providers
+ # This queries the Gemini and Groq registries for current quota status
+ fresh_response = _collect_all_provider_status()
+
+ # Store in cache for future requests
+ _set_cached_response(fresh_response)
+
+ return fresh_response # type: ignore[return-value]
+
+ return router
+
+
+# =============================================================================
+# Lazy Loading Wrappers
+# =============================================================================
+
+
+class ModelStatusResponse:
+ """Response model for individual LLM model status.
+
+ This is a lazy-loading wrapper that creates the actual Pydantic model
+ on first instantiation. This avoids importing pydantic at module load time.
+
+ Attributes
+ ----------
+ model_name: Identifier for the model.
+ is_available: Whether the model can currently accept requests.
+ requests_remaining_minute: Requests remaining in current minute.
+ tokens_remaining_minute: Tokens remaining in current minute.
+ requests_remaining_day: Requests remaining today.
+ tokens_remaining_day: Tokens remaining today (Groq only, None for Gemini).
+ cooldown_seconds: Seconds until cooldown ends (None if not in cooldown).
+ reason: Human-readable reason for unavailability (None if available).
+
+ See _create_model_status_response_class() for the actual Pydantic model.
+
+ """
+
+ # Type stubs for mypy
+ model_name: str
+ is_available: bool
+ requests_remaining_minute: int
+ tokens_remaining_minute: int
+ requests_remaining_day: int
+ tokens_remaining_day: int | None
+ cooldown_seconds: int | None
+ reason: str | None
+
+ def __new__( # noqa: PLR0913
+ cls,
+ model_name: str = "",
+ is_available: bool = False,
+ requests_remaining_minute: int = 0,
+ tokens_remaining_minute: int = 0,
+ requests_remaining_day: int = 0,
+ tokens_remaining_day: int | None = None,
+ cooldown_seconds: int | None = None,
+ reason: str | None = None,
+ ) -> ModelStatusResponse:
+ """Create a new ModelStatusResponse instance using lazy-loaded class.
+
+ Args:
+ ----
+ model_name: Identifier for the model.
+ is_available: Whether model can accept requests.
+ requests_remaining_minute: Requests remaining in current minute.
+ tokens_remaining_minute: Tokens remaining in current minute.
+ requests_remaining_day: Requests remaining today.
+ tokens_remaining_day: Tokens remaining today (Groq only).
+ cooldown_seconds: Seconds until cooldown ends.
+ reason: Human-readable reason for unavailability.
+
+ Returns:
+ -------
+ A ModelStatusResponse instance.
+
+ """
+ global _ModelStatusResponseClass # noqa: PLW0603
+ if _ModelStatusResponseClass is None:
+ _ModelStatusResponseClass = _create_model_status_response_class()
+ instance: object = _ModelStatusResponseClass( # type: ignore[call-arg]
+ model_name=model_name,
+ is_available=is_available,
+ requests_remaining_minute=requests_remaining_minute,
+ tokens_remaining_minute=tokens_remaining_minute,
+ requests_remaining_day=requests_remaining_day,
+ tokens_remaining_day=tokens_remaining_day,
+ cooldown_seconds=cooldown_seconds,
+ reason=reason,
+ )
+ return instance # type: ignore[return-value]
+
+
+class ProviderStatusResponse:
+ """Response model for a single LLM provider's status.
+
+ This is a lazy-loading wrapper that creates the actual Pydantic model
+ on first instantiation. This avoids importing pydantic at module load time.
+
+ Attributes
+ ----------
+ provider: Provider identifier ("gemini" or "groq").
+ is_available: Whether any model in this provider is available.
+ models: List of ModelStatusResponse for each model.
+
+ See _create_provider_status_response_class() for the actual Pydantic model.
+
+ """
+
+ # Type stubs for mypy
+ provider: str
+ is_available: bool
+ models: list[ModelStatusResponse]
+
+ def __new__(
+ cls,
+ provider: str = "",
+ is_available: bool = False,
+ models: list[ModelStatusResponse] | None = None,
+ ) -> ProviderStatusResponse:
+ """Create a new ProviderStatusResponse instance using lazy-loaded class.
+
+ Args:
+ ----
+ provider: Provider identifier.
+ is_available: Whether any model is available.
+ models: List of model status responses.
+
+ Returns:
+ -------
+ A ProviderStatusResponse instance.
+
+ """
+ global _ProviderStatusResponseClass # noqa: PLW0603
+ if _ProviderStatusResponseClass is None:
+ _ProviderStatusResponseClass = _create_provider_status_response_class()
+ if models is None:
+ models = []
+ instance: object = _ProviderStatusResponseClass( # type: ignore[call-arg]
+ provider=provider,
+ is_available=is_available,
+ models=models,
+ )
+ return instance # type: ignore[return-value]
+
+
+class ProvidersResponse:
+ """Response model for the GET /providers endpoint.
+
+ This is a lazy-loading wrapper that creates the actual Pydantic model
+ on first instantiation. This avoids importing pydantic at module load time.
+
+ Attributes
+ ----------
+ providers: List of ProviderStatusResponse for each provider.
+ cached: Whether this response was served from cache.
+ cache_expires_in_seconds: Seconds until cache expires.
+
+ See _create_providers_response_class() for the actual Pydantic model.
+
+ """
+
+ # Type stubs for mypy
+ providers: list[ProviderStatusResponse]
+ cached: bool
+ cache_expires_in_seconds: int | None
+
+ def __new__(
+ cls,
+ providers: list[ProviderStatusResponse] | None = None,
+ cached: bool = False,
+ cache_expires_in_seconds: int | None = None,
+ ) -> ProvidersResponse:
+ """Create a new ProvidersResponse instance using lazy-loaded class.
+
+ Args:
+ ----
+ providers: List of provider status responses.
+ cached: Whether response is from cache.
+ cache_expires_in_seconds: Seconds until cache expires.
+
+ Returns:
+ -------
+ A ProvidersResponse instance.
+
+ """
+ global _ProvidersResponseClass # noqa: PLW0603
+ if _ProvidersResponseClass is None:
+ _ProvidersResponseClass = _create_providers_response_class()
+ if providers is None:
+ providers = []
+ instance: object = _ProvidersResponseClass( # type: ignore[call-arg]
+ providers=providers,
+ cached=cached,
+ cache_expires_in_seconds=cache_expires_in_seconds,
+ )
+ return instance # type: ignore[return-value]
+
+
+def _get_router() -> APIRouter:
+ """Get the router instance, creating it on first access.
+
+ Returns
+ -------
+ The APIRouter instance with provider status endpoint.
+
+ """
+ global _router_instance # noqa: PLW0603
+ if _router_instance is None:
+ _router_instance = _create_router()
+ return _router_instance
+
+
+# =============================================================================
+# Module-level Router
+# =============================================================================
+# Use a proxy pattern for lazy loading. The actual router is created on
+# first access to avoid importing FastAPI at module load time.
+# =============================================================================
+
+
+class _RouterProxy:
+ """Proxy object that lazily creates the router on first access.
+
+ This allows the router to be accessed as a module-level attribute
+ while deferring FastAPI import until first use.
+
+ """
+
+ def __getattr__(self, name: str) -> object:
+ """Forward attribute access to the actual router.
+
+ Args:
+ ----
+ name: Attribute name to access.
+
+ Returns:
+ -------
+ The attribute from the actual router.
+
+ """
+ return getattr(_get_router(), name)
+
+ def __repr__(self) -> str:
+ """Return string representation.
+
+ Returns
+ -------
+ String representation of the router proxy.
+
+ """
+ return repr(_get_router())
+
+
+# Create the router proxy for lazy loading
+router: _RouterProxy = _RouterProxy()
diff --git a/src/rag_chatbot/api/routes/query.py b/src/rag_chatbot/api/routes/query.py
index 5481d3924112a8497c0f10f5895c328fc1b3c754..de1634eb94bec03cc37dd86f4798beac3b8ec62f 100644
--- a/src/rag_chatbot/api/routes/query.py
+++ b/src/rag_chatbot/api/routes/query.py
@@ -8,97 +8,1324 @@ The endpoint:
- Streams responses using Server-Sent Events (SSE)
Lazy Loading:
- FastAPI and SSE dependencies are loaded on first use.
+ FastAPI and Pydantic dependencies are loaded on first use to avoid
+ import overhead for CLI tools that don't need the web server.
-Note:
-----
- This is a placeholder that will be fully implemented in Step 4.1.
+Request/Response Models:
+ - QueryRequest: Validates incoming query parameters
+ - QueryResponse: Structures non-streaming responses
+ - SourceReference: Represents a retrieved source chunk
+
+Supported Providers:
+ - gemini: Uses ProviderRegistry with Gemini models
+ - groq: Uses GroqProviderRegistry with Groq models
+
+Architecture:
+ The query endpoint follows this pipeline:
+ 1. Validate request (provider, query, top_k)
+ 2. Ensure resources are loaded (retriever, settings)
+ 3. Retrieve relevant context chunks
+ 4. Build LLM request with context
+ 5. Generate response with fallback on rate limits
+ 6. Return response with sources (non-streaming) or SSE stream
+
+Error Handling:
+ - 400 Bad Request: Invalid provider
+ - 422 Validation Error: Invalid request body
+ - 500 Internal Server Error: Unexpected errors
+ - 503 Service Unavailable: All LLM models rate limited
"""
from __future__ import annotations
-from typing import Any
+import logging
+import time
+from typing import TYPE_CHECKING, Literal
+
+if TYPE_CHECKING:
+ from fastapi import APIRouter
+
+ from rag_chatbot.llm.base import LLMRequest, LLMResponse
+ from rag_chatbot.llm.groq_registry import GroqProviderRegistry
+ from rag_chatbot.llm.registry import ProviderRegistry
+ from rag_chatbot.retrieval.models import RetrievalResult
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["router"]
+# Note: router is defined via __getattr__ for lazy loading
+__all__: list[str] = [
+ "router", # noqa: F822
+ "QueryRequest",
+ "QueryResponse",
+ "SourceReference",
+]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Constants
+# =============================================================================
+# Maximum length of query to show in log messages before truncation
+_MAX_QUERY_LOG_LENGTH: int = 50
-# Placeholder for the router - will be initialized in Step 4.1
-# Using None with type annotation to satisfy type checker
-router: Any = None # Will be APIRouter instance
+# =============================================================================
+# Valid Provider Types
+# =============================================================================
+# Define valid LLM providers as a Literal type for validation.
+# Currently only gemini and groq are supported.
+# deepseek and anthropic will be added in future iterations.
+VALID_PROVIDERS: tuple[str, ...] = ("gemini", "groq")
+ProviderType = Literal["gemini", "groq"]
-class QueryRequest:
- """Request model for query endpoint.
+# =============================================================================
+# Pydantic Models (Lazy Loaded)
+# =============================================================================
+# These classes use deferred imports to avoid loading Pydantic at module
+# import time. The actual Pydantic BaseModel is imported inside __init_subclass__
+# or when the class is first instantiated.
- Attributes:
- ----------
- question: User's question about pythermalcomfort.
- top_k: Number of context chunks to retrieve.
- stream: Whether to stream the response.
- Note:
+def _get_base_model() -> type:
+ """Lazily import and return Pydantic BaseModel.
+
+ Returns
+ -------
+ type: The Pydantic BaseModel class.
+
+ """
+ from pydantic import BaseModel
+
+ return BaseModel
+
+
+def _create_source_reference_model() -> type:
+ """Create the SourceReference Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time.
+
+ Returns
+ -------
+ type: The SourceReference model class.
+
+ """
+ from pydantic import BaseModel, Field
+
+ class _SourceReference(BaseModel):
+ """Reference to a source chunk used in generating the response.
+
+ This model represents a single chunk from the document store that
+ was retrieved as context for generating the LLM response.
+
+ Attributes
+ ----------
+ chunk_id: Unique identifier for the chunk.
+ text: The text content of the chunk.
+ source: Original filename the chunk was extracted from.
+ page: Page number in the source document (if applicable).
+ heading_path: Hierarchical path of headings leading to this chunk.
+ score: Relevance score from the retriever (higher is more relevant).
+
+ """
+
+ chunk_id: str = Field(
+ ...,
+ description="Unique identifier for the chunk",
+ )
+ text: str = Field(
+ ...,
+ description="The text content of the chunk",
+ )
+ source: str = Field(
+ ...,
+ description="Original filename the chunk was extracted from",
+ )
+ page: int | None = Field(
+ default=None,
+ description="Page number in the source document (if applicable)",
+ )
+ heading_path: list[str] = Field(
+ default_factory=list,
+ description="Hierarchical path of headings leading to this chunk",
+ )
+ score: float = Field(
+ ...,
+ description="Relevance score from the retriever (higher is more relevant)",
+ ge=0.0,
+ )
+
+ model_config = {
+ "json_schema_extra": {
+ "examples": [
+ {
+ "chunk_id": "chunk_001",
+ "text": "The PMV model calculates...",
+ "source": "pythermalcomfort_docs.pdf",
+ "page": 15,
+ "heading_path": ["Thermal Comfort Models", "PMV-PPD"],
+ "score": 0.85,
+ }
+ ]
+ }
+ }
+
+ return _SourceReference
+
+
+def _create_query_request_model() -> type:
+ """Create the QueryRequest Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time.
+
+ Returns
+ -------
+ type: The QueryRequest model class.
+
+ """
+ from pydantic import BaseModel, Field, field_validator
+
+ # Import MAX_HISTORY_MESSAGES for validation
+ from rag_chatbot.llm.prompts import MAX_HISTORY_MESSAGES
+
+ class _QueryRequest(BaseModel):
+ """Request model for the query endpoint.
+
+ This model validates incoming query requests and provides
+ defaults for optional parameters.
+
+ Attributes:
+ ----------
+ query: The user's question about pythermalcomfort.
+ provider: LLM provider to use for generation.
+ top_k: Number of context chunks to retrieve.
+ stream: Whether to stream the response via SSE.
+ history: Previous conversation messages for multi-turn context.
+
+ Example:
+ -------
+ >>> request = QueryRequest(
+ ... query="What is PMV?",
+ ... provider="gemini",
+ ... top_k=6,
+ ... stream=True,
+ ... history=[
+ ... {"role": "user", "content": "Hello"},
+ ... {"role": "assistant", "content": "Hi there!"},
+ ... ],
+ ... )
+
+ """
+
+ query: str = Field(
+ ...,
+ min_length=1,
+ max_length=2000,
+ description="The user's question about pythermalcomfort",
+ )
+ provider: str = Field(
+ default="gemini",
+ description="LLM provider to use for generation",
+ )
+ top_k: int = Field(
+ default=6,
+ ge=1,
+ le=20,
+ description="Number of context chunks to retrieve",
+ )
+ stream: bool = Field(
+ default=True,
+ description="Whether to stream the response via SSE",
+ )
+ history: list[dict[str, str]] = Field(
+ default_factory=list,
+ max_length=MAX_HISTORY_MESSAGES * 2, # Allow some buffer
+ description=(
+ "Previous conversation messages for multi-turn context. "
+ "Each message should have 'role' (user/assistant) and 'content' keys."
+ ),
+ )
+
+ @field_validator("provider")
+ @classmethod
+ def validate_provider(cls, v: str) -> str:
+ """Validate that provider is one of the allowed values.
+
+ Args:
+ ----
+ v: The provider value to validate.
+
+ Returns:
+ -------
+ str: The validated provider value.
+
+ Raises:
+ ------
+ ValueError: If provider is not in the allowed list.
+
+ """
+ if v not in VALID_PROVIDERS:
+ msg = f"provider must be one of: {', '.join(VALID_PROVIDERS)}"
+ raise ValueError(msg)
+ return v
+
+ @field_validator("history")
+ @classmethod
+ def validate_history(
+ cls, v: list[dict[str, str]]
+ ) -> list[dict[str, str]]:
+ """Validate that history messages have correct structure.
+
+ Args:
+ ----
+ v: The history list to validate.
+
+ Returns:
+ -------
+ list[dict[str, str]]: The validated history list.
+
+ Raises:
+ ------
+ ValueError: If any message has invalid structure.
+
+ """
+ for i, msg in enumerate(v):
+ # Check required keys
+ if "role" not in msg:
+ raise ValueError(f"history[{i}] missing 'role' key")
+ if "content" not in msg:
+ raise ValueError(f"history[{i}] missing 'content' key")
+
+ # Validate role value
+ if msg["role"] not in ("user", "assistant"):
+ raise ValueError(
+ f"history[{i}] role must be 'user' or 'assistant', "
+ f"got '{msg['role']}'"
+ )
+
+ # Validate content is non-empty string
+ if not isinstance(msg["content"], str) or not msg["content"].strip():
+ raise ValueError(
+ f"history[{i}] content must be a non-empty string"
+ )
+
+ return v
+
+ model_config = {
+ "json_schema_extra": {
+ "examples": [
+ {
+ "query": "What is the PMV model and how do I use it?",
+ "provider": "gemini",
+ "top_k": 6,
+ "stream": True,
+ "history": [],
+ },
+ {
+ "query": "How do I calculate it?",
+ "provider": "gemini",
+ "top_k": 6,
+ "stream": True,
+ "history": [
+ {"role": "user", "content": "What is PMV?"},
+ {"role": "assistant", "content": "PMV stands for..."},
+ ],
+ },
+ ]
+ }
+ }
+
+ return _QueryRequest
+
+
+def _create_query_response_model(source_reference_class: type) -> type:
+ """Create the QueryResponse Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time.
+
+ Args:
----
- This class will be converted to a Pydantic model in Step 4.1.
+ source_reference_class: The SourceReference model class to use
+ for typing the sources field.
+
+ Returns:
+ -------
+ type: The QueryResponse model class.
+
+ """
+ from pydantic import BaseModel, Field
+
+ class _QueryResponse(BaseModel):
+ """Response model for non-streaming query responses.
+
+ This model structures the complete response for queries
+ that do not use streaming (stream=False).
+
+ Attributes:
+ ----------
+ query: The original query that was submitted.
+ response: The LLM-generated response text.
+ sources: List of source chunks used as context.
+ provider: The LLM provider that generated the response.
+ model: The specific model used for generation.
+ latency_ms: Response generation time in milliseconds.
+
+ Note:
+ ----
+ For streaming responses (stream=True), the response is
+ sent via Server-Sent Events (SSE) instead of this model.
+
+ """
+
+ query: str = Field(
+ ...,
+ description="The original query that was submitted",
+ )
+ response: str = Field(
+ ...,
+ description="The LLM-generated response text",
+ )
+ sources: list[source_reference_class] = Field( # type: ignore[valid-type]
+ default_factory=list,
+ description="List of source chunks used as context",
+ )
+ provider: str = Field(
+ ...,
+ description="The LLM provider that generated the response",
+ )
+ model: str = Field(
+ ...,
+ description="The specific model used for generation",
+ )
+ latency_ms: int = Field(
+ ...,
+ description="Response generation time in milliseconds",
+ ge=0,
+ )
+
+ model_config = {
+ "json_schema_extra": {
+ "examples": [
+ {
+ "query": "What is the PMV model?",
+ "response": "The PMV (Predicted Mean Vote) model is...",
+ "sources": [
+ {
+ "chunk_id": "chunk_001",
+ "text": "The PMV model calculates...",
+ "source": "pythermalcomfort_docs.pdf",
+ "page": 15,
+ "heading_path": ["Thermal Comfort Models", "PMV-PPD"],
+ "score": 0.85,
+ }
+ ],
+ "provider": "gemini",
+ "model": "gemini-1.5-flash",
+ "latency_ms": 1250,
+ }
+ ]
+ }
+ }
+
+ return _QueryResponse
+
+
+# =============================================================================
+# Model Class Proxies
+# =============================================================================
+# These classes act as proxies that defer model creation until first use.
+# This enables lazy loading while maintaining the appearance of regular classes.
+
+_source_reference_model: type | None = None
+_query_request_model: type | None = None
+_query_response_model: type | None = None
+
+
+def _get_source_reference() -> type:
+ """Get or create the SourceReference model class.
+
+ Returns
+ -------
+ type: The SourceReference Pydantic model class.
+
+ """
+ global _source_reference_model # noqa: PLW0603
+ if _source_reference_model is None:
+ _source_reference_model = _create_source_reference_model()
+ return _source_reference_model
+
+
+def _get_query_request() -> type:
+ """Get or create the QueryRequest model class.
+
+ Returns
+ -------
+ type: The QueryRequest Pydantic model class.
+
+ """
+ global _query_request_model # noqa: PLW0603
+ if _query_request_model is None:
+ _query_request_model = _create_query_request_model()
+ return _query_request_model
+
+
+def _get_query_response() -> type:
+ """Get or create the QueryResponse model class.
+
+ Returns
+ -------
+ type: The QueryResponse Pydantic model class.
+
+ """
+ global _query_response_model # noqa: PLW0603
+ if _query_response_model is None:
+ source_ref = _get_source_reference()
+ _query_response_model = _create_query_response_model(source_ref)
+ return _query_response_model
+
+
+# =============================================================================
+# Public Model Classes (Module-level Exports)
+# =============================================================================
+# These are the public-facing model classes. They use __class_getitem__ and
+# other magic methods to delegate to the lazily-loaded implementations.
+
+
+class SourceReference:
+ """Reference to a source chunk used in generating the response.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
+ ----------
+ chunk_id: Unique identifier for the chunk.
+ text: The text content of the chunk.
+ source: Original filename the chunk was extracted from.
+ page: Page number in the source document (if applicable).
+ heading_path: Hierarchical path of headings leading to this chunk.
+ score: Relevance score from the retriever (higher is more relevant).
"""
- def __init__(
- self,
- question: str,
- top_k: int = 6,
- stream: bool = True,
- ) -> None:
- """Initialize a query request.
+ def __new__(cls, **kwargs: object) -> SourceReference:
+ """Create a new SourceReference instance.
Args:
----
- question: User's question.
- top_k: Number of context chunks. Defaults to 6.
- stream: Whether to stream. Defaults to True.
+ **kwargs: Field values for the model.
- Raises:
- ------
- NotImplementedError: QueryRequest will be implemented in Step 4.1.
+ Returns:
+ -------
+ SourceReference: A SourceReference Pydantic model instance.
+
+ """
+ model_class = _get_source_reference()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> SourceReference:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ SourceReference: Validated SourceReference instance.
+
+ """
+ model_class = _get_source_reference()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+
+class QueryRequest:
+ """Request model for the query endpoint.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
+ ----------
+ query: The user's question about pythermalcomfort (1-2000 chars).
+ provider: LLM provider (gemini, groq).
+ top_k: Number of context chunks to retrieve (1-20).
+ stream: Whether to stream the response via SSE.
+
+ """
+
+ def __new__(cls, **kwargs: object) -> QueryRequest:
+ """Create a new QueryRequest instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model.
+
+ Returns:
+ -------
+ QueryRequest: A QueryRequest Pydantic model instance.
"""
- raise NotImplementedError("QueryRequest will be implemented in Step 4.1")
+ model_class = _get_query_request()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> QueryRequest:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ QueryRequest: Validated QueryRequest instance.
+
+ """
+ model_class = _get_query_request()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
class QueryResponse:
- """Response model for query endpoint.
+ """Response model for non-streaming query responses.
- Attributes:
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
----------
- answer: Generated answer text.
- sources: List of source chunk IDs used.
- provider: LLM provider that generated the response.
+ query: The original query that was submitted.
+ response: The LLM-generated response text.
+ sources: List of source chunks used as context.
+ provider: The LLM provider that generated the response.
+ model: The specific model used for generation.
+ latency_ms: Response generation time in milliseconds.
+
+ """
+
+ def __new__(cls, **kwargs: object) -> QueryResponse:
+ """Create a new QueryResponse instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model.
+
+ Returns:
+ -------
+ QueryResponse: A QueryResponse Pydantic model instance.
+
+ """
+ model_class = _get_query_response()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> QueryResponse:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ QueryResponse: Validated QueryResponse instance.
+
+ """
+ model_class = _get_query_response()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+
+# =============================================================================
+# Helper Functions
+# =============================================================================
+
+
+def _build_context_from_retrieval_results(
+ results: list[RetrievalResult],
+) -> list[str]:
+ """Build a list of context strings from retrieval results.
+
+ Formats each retrieved chunk into a context string that can be
+ included in the LLM prompt. Each context string includes:
+ - Source document and page reference
+ - Heading path for context
+ - The actual chunk text
+
+ Args:
+ ----
+ results: List of RetrievalResult objects from the retriever.
+
+ Returns:
+ -------
+ List of formatted context strings, one per retrieved chunk.
+
+ Example:
+ -------
+ >>> results = retriever.retrieve("What is PMV?", top_k=3)
+ >>> context = _build_context_from_retrieval_results(results)
+ >>> len(context) == len(results)
+ True
+
+ Note:
+ ----
+ The context format is designed to help the LLM understand where
+ the information comes from and the hierarchical structure of
+ the documentation.
+
+ """
+ context_strings: list[str] = []
+
+ for result in results:
+ # Build heading path string if available
+ heading_str = ""
+ if result.heading_path:
+ heading_str = " > ".join(result.heading_path)
+ heading_str = f"\n[{heading_str}]"
+
+ # Format source reference
+ source_ref = f"[Source: {result.source}"
+ if result.page:
+ source_ref += f", Page {result.page}"
+ source_ref += "]"
+
+ # Combine into full context string
+ context_string = f"{source_ref}{heading_str}\n{result.text}"
+ context_strings.append(context_string)
+
+ return context_strings
+
+
+def _build_source_references(results: list[RetrievalResult]) -> list[object]:
+ """Convert retrieval results to SourceReference models.
+
+ Creates SourceReference model instances from the retrieval results
+ for inclusion in the QueryResponse.
+
+ Args:
+ ----
+ results: List of RetrievalResult objects from the retriever.
+
+ Returns:
+ -------
+ List of SourceReference model instances.
+
+ """
+ source_ref_class = _get_source_reference()
+ sources: list[object] = []
+
+ for result in results:
+ source = source_ref_class(
+ chunk_id=result.chunk_id,
+ text=result.text,
+ source=result.source,
+ page=result.page,
+ heading_path=result.heading_path,
+ score=result.score,
+ )
+ sources.append(source)
+
+ return sources
+
+
+def _get_provider_registry(
+ provider: str,
+ settings: object,
+ timeout_ms: int,
+) -> ProviderRegistry | GroqProviderRegistry:
+ """Get the appropriate provider registry based on provider name.
+
+ Factory function that returns the correct registry class for the
+ specified provider. Each provider has its own registry with specific
+ model configurations and quota tracking.
+
+ Args:
+ ----
+ provider: The provider name ("gemini" or "groq").
+ settings: The application Settings instance.
+ timeout_ms: Request timeout in milliseconds.
+
+ Returns:
+ -------
+ The appropriate provider registry instance.
+
+ Raises:
+ ------
+ ValueError: If the provider is not supported.
Note:
----
- This class will be converted to a Pydantic model in Step 4.1.
+ Registries are created on each request. This is intentional because:
+ - QuotaManager tracks per-minute usage which resets
+ - Creates isolation between requests
+ - Allows for configuration changes without restart
+
+ In a production environment, you might want to cache registries
+ at the application level and share them across requests for better
+ quota tracking accuracy.
+
+ """
+ # Lazy import to avoid loading LLM modules at module import time
+ if provider == "gemini":
+ from rag_chatbot.llm.registry import ProviderRegistry
+
+ api_key = getattr(settings, "gemini_api_key", None)
+ if not api_key:
+ msg = "GEMINI_API_KEY is not configured"
+ raise ValueError(msg)
+
+ return ProviderRegistry(api_key=api_key, timeout_ms=timeout_ms)
+
+ elif provider == "groq":
+ from rag_chatbot.llm.groq_registry import GroqProviderRegistry
+
+ api_key = getattr(settings, "groq_api_key", None)
+ if not api_key:
+ msg = "GROQ_API_KEY is not configured"
+ raise ValueError(msg)
+
+ return GroqProviderRegistry(api_key=api_key, timeout_ms=timeout_ms)
+
+ else:
+ supported = ", ".join(VALID_PROVIDERS)
+ msg = f"Unsupported provider: {provider}. Supported: {supported}"
+ raise ValueError(msg)
+
+
+def _build_llm_request(
+ query: str,
+ context: list[str],
+ history: list[dict[str, str]] | None = None,
+) -> LLMRequest:
+ """Build an LLMRequest from the query, context, and conversation history.
+
+ Creates an LLMRequest with the user query, retrieved context chunks,
+ and optional conversation history for multi-turn support.
+
+ Args:
+ ----
+ query: The user's question.
+ context: List of context strings from retrieval.
+ history: Optional list of previous conversation messages.
+ Each message is a dict with 'role' and 'content' keys.
+
+ Returns:
+ -------
+ LLMRequest ready to be sent to the provider.
+
+ Example:
+ -------
+ >>> # Single-turn request (no history)
+ >>> request = _build_llm_request("What is PMV?", ["PMV stands for..."])
+ >>>
+ >>> # Multi-turn request with history
+ >>> history = [
+ ... {"role": "user", "content": "Hello"},
+ ... {"role": "assistant", "content": "Hi!"},
+ ... ]
+ >>> request = _build_llm_request("What is PMV?", ["..."], history)
"""
+ from rag_chatbot.llm.base import ChatMessage, LLMRequest as LLMRequestClass
- def __init__(
- self,
- answer: str,
- sources: list[str],
- provider: str,
- ) -> None:
- """Initialize a query response.
+ # Convert history dicts to ChatMessage objects
+ chat_history: list[ChatMessage] = []
+ if history:
+ for msg in history:
+ # Validate and create ChatMessage
+ # The validator in ChatMessage handles content stripping
+ role = msg.get("role", "user")
+ content = msg.get("content", "")
+
+ # Skip invalid messages (shouldn't happen after API validation)
+ if role in ("user", "assistant") and content.strip():
+ chat_history.append(
+ ChatMessage(
+ role=role, # type: ignore[arg-type]
+ content=content,
+ )
+ )
+
+ return LLMRequestClass(
+ query=query,
+ context=context,
+ max_tokens=1024,
+ temperature=0.7,
+ history=chat_history,
+ )
+
+
+# =============================================================================
+# Router Factory
+# =============================================================================
+def _create_router() -> APIRouter: # noqa: PLR0915
+ """Create and configure the query router.
+
+ This factory function creates the APIRouter with all endpoints
+ configured. It uses lazy imports to defer loading of FastAPI.
+
+ Returns:
+ -------
+ APIRouter: Configured router with query endpoints.
+
+ Note:
+ ----
+ The PLR0915 (too many statements) check is disabled because
+ this is a factory function that creates a complete router with
+ decorators and an endpoint. Breaking it into smaller pieces
+ would reduce readability without improving maintainability.
+
+ """
+ from fastapi import APIRouter, Body, HTTPException
+ from fastapi.responses import JSONResponse, StreamingResponse
+
+ # Create router with no prefix (will be mounted at /api by main.py)
+ router = APIRouter(tags=["Query"])
+
+ # Get the actual Pydantic model classes for type hints
+ # Using PascalCase since these are class references (noqa: N806)
+ request_model = _get_query_request()
+ response_model = _get_query_response()
+
+ @router.post(
+ "/query",
+ response_model=response_model,
+ responses={
+ 200: {
+ "description": "Successful response (non-streaming)",
+ "model": response_model,
+ },
+ 400: {
+ "description": "Invalid provider or missing API key",
+ },
+ 503: {
+ "description": "All models rate limited - retry after delay",
+ "headers": {
+ "Retry-After": {
+ "description": "Seconds to wait before retrying",
+ "schema": {"type": "integer"},
+ }
+ },
+ },
+ },
+ summary="Query the pythermalcomfort knowledge base",
+ description=(
+ "Submit a question about pythermalcomfort and receive an AI-generated "
+ "response with source citations.\n\n"
+ "**Retrieval**: The query is processed through a hybrid retrieval "
+ "system (dense embeddings + BM25) to find the most relevant "
+ "documentation chunks.\n\n"
+ "**Generation**: Retrieved chunks are passed to the LLM provider "
+ "as context to generate a grounded response.\n\n"
+ "**Streaming**: When `stream=True` (default), the response is "
+ "streamed via Server-Sent Events (SSE). When `stream=False`, "
+ "a complete JSON response is returned.\n\n"
+ "**Providers**: Currently supports 'gemini' and 'groq' providers."
+ ),
+ )
+ async def query_endpoint( # noqa: PLR0912, PLR0915
+ request: request_model = Body(...), # type: ignore[valid-type]
+ ) -> JSONResponse | StreamingResponse:
+ """Process a query against the pythermalcomfort knowledge base.
+
+ This endpoint accepts a user question, retrieves relevant context
+ from the document store, and generates a response using the
+ configured LLM provider.
+
+ Processing Pipeline:
+ 1. Validate request parameters
+ 2. Ensure resources are loaded (retriever, settings)
+ 3. Retrieve relevant context chunks using hybrid retrieval
+ 4. Build LLM request with formatted context
+ 5. Generate response using provider with fallback
+ 6. Return structured response with sources
Args:
----
- answer: Generated answer text.
- sources: List of source chunk IDs.
- provider: LLM provider used.
+ request: The query request containing the question and options.
+
+ Returns:
+ -------
+ JSONResponse with QueryResponse body for non-streaming requests.
+ For streaming requests (stream=True), returns a placeholder
+ that will be replaced by SSE streaming in a future implementation.
Raises:
------
- NotImplementedError: QueryResponse will be implemented in Step 4.1.
+ HTTPException 400: Invalid provider or missing API key.
+ HTTPException 503: All models rate limited (includes Retry-After header).
+ HTTPException 500: Unexpected server error.
"""
- raise NotImplementedError("QueryResponse will be implemented in Step 4.1")
+ # Track request start time for latency calculation
+ start_time = time.perf_counter()
+
+ # Extract request fields (type: ignore for dynamic model)
+ query_text: str = request.query # type: ignore[attr-defined]
+ provider: str = request.provider # type: ignore[attr-defined]
+ top_k: int = request.top_k # type: ignore[attr-defined]
+ stream: bool = request.stream # type: ignore[attr-defined]
+ history: list[dict[str, str]] = request.history # type: ignore[attr-defined]
+
+ # Log truncated query for debugging
+ query_preview = (
+ query_text[:_MAX_QUERY_LOG_LENGTH] + "..."
+ if len(query_text) > _MAX_QUERY_LOG_LENGTH
+ else query_text
+ )
+ logger.info(
+ "Processing query: provider=%s, top_k=%d, stream=%s, query=%r",
+ provider,
+ top_k,
+ stream,
+ query_preview,
+ )
+
+ # =====================================================================
+ # Step 1: Handle streaming requests via SSE
+ # =====================================================================
+ # For streaming requests, we use Server-Sent Events (SSE) to deliver
+ # the response in real-time as the LLM generates tokens. This requires:
+ # 1. Loading resources (retriever, settings)
+ # 2. Retrieving relevant context chunks
+ # 3. Building the LLM request
+ # 4. Creating a provider registry
+ # 5. Returning a StreamingResponse with the SSE event generator
+ # =====================================================================
+ if stream:
+ logger.debug("Streaming requested - delegating to SSE handler")
+
+ # Import SSE streaming utilities
+ # ---------------------------------------------------------------
+ # Load resources for streaming
+ # ---------------------------------------------------------------
+ from rag_chatbot.api.resources import get_resource_manager
+
+ from ..sse import stream_sse_response
+
+ resource_manager = get_resource_manager()
+
+ try:
+ await resource_manager.ensure_loaded()
+ except RuntimeError as e:
+ logger.exception("Failed to load resources for streaming")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to initialize resources: {e}",
+ ) from e
+
+ retriever = resource_manager.get_retriever()
+ settings = resource_manager.get_settings()
+
+ # ---------------------------------------------------------------
+ # Retrieve context chunks for streaming
+ # ---------------------------------------------------------------
+ logger.debug("Retrieving context for streaming with top_k=%d", top_k)
+
+ try:
+ retrieval_results = retriever.retrieve(query_text, top_k=top_k)
+ except (ValueError, RuntimeError) as e:
+ logger.exception("Retrieval failed for streaming")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Retrieval failed: {e}",
+ ) from e
+
+ logger.info(
+ "Retrieved %d context chunks for streaming", len(retrieval_results)
+ )
+
+ # ---------------------------------------------------------------
+ # Build LLM request for streaming (with conversation history)
+ # ---------------------------------------------------------------
+ context_strings = _build_context_from_retrieval_results(retrieval_results)
+ llm_request = _build_llm_request(query_text, context_strings, history)
+
+ # ---------------------------------------------------------------
+ # Get provider registry for streaming
+ # ---------------------------------------------------------------
+ try:
+ registry = _get_provider_registry(
+ provider=provider,
+ settings=settings,
+ timeout_ms=settings.provider_timeout_ms,
+ )
+ except ValueError as e:
+ logger.warning("Provider configuration error for streaming: %s", e)
+ raise HTTPException(
+ status_code=400,
+ detail=str(e),
+ ) from e
+
+ # Check if any model is available before streaming
+ if not registry.is_available:
+ logger.warning(
+ "No models available for streaming provider: %s", provider
+ )
+ detail_msg = (
+ f"No {provider} models are currently available. Try again later."
+ )
+ raise HTTPException(
+ status_code=503,
+ detail=detail_msg,
+ headers={"Retry-After": "60"},
+ )
+
+ # ---------------------------------------------------------------
+ # Return SSE streaming response
+ # ---------------------------------------------------------------
+ # The stream_sse_response generator handles:
+ # - Token events as chunks are generated
+ # - Done event with full response and metadata
+ # - Error events for rate limits and other failures
+ # - Client disconnect handling
+ # ---------------------------------------------------------------
+ logger.info(
+ "Starting SSE stream: provider=%s, sources=%d",
+ provider,
+ len(retrieval_results),
+ )
+
+ return StreamingResponse(
+ stream_sse_response(
+ registry=registry,
+ request=llm_request,
+ sources=retrieval_results,
+ preferred_model=None,
+ ),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no", # Disable nginx buffering
+ },
+ )
+
+ # =====================================================================
+ # Step 2: Ensure resources are loaded
+ # =====================================================================
+ # The ResourceManager handles lazy loading of the retriever and settings.
+ # This may take 10-30 seconds on cold start (first request).
+ # =====================================================================
+ from rag_chatbot.api.resources import get_resource_manager
+
+ resource_manager = get_resource_manager()
+
+ try:
+ await resource_manager.ensure_loaded()
+ except RuntimeError as e:
+ logger.exception("Failed to load resources")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to initialize resources: {e}",
+ ) from e
+
+ # Get retriever and settings from resource manager
+ retriever = resource_manager.get_retriever()
+ settings = resource_manager.get_settings()
+
+ # =====================================================================
+ # Step 3: Retrieve relevant context chunks
+ # =====================================================================
+ # The HybridRetriever combines dense (FAISS) and sparse (BM25) retrieval
+ # using Reciprocal Rank Fusion for optimal results.
+ # =====================================================================
+ logger.debug("Retrieving context with top_k=%d", top_k)
+
+ try:
+ retrieval_results = retriever.retrieve(query_text, top_k=top_k)
+ except (ValueError, RuntimeError) as e:
+ logger.exception("Retrieval failed")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Retrieval failed: {e}",
+ ) from e
+
+ logger.info("Retrieved %d context chunks", len(retrieval_results))
+
+ # =====================================================================
+ # Step 4: Build context strings and LLM request (with history)
+ # =====================================================================
+ context_strings = _build_context_from_retrieval_results(retrieval_results)
+ llm_request = _build_llm_request(query_text, context_strings, history)
+
+ # =====================================================================
+ # Step 5: Get provider registry and generate response
+ # =====================================================================
+ # The registry handles model selection and fallback on rate limits.
+ # =====================================================================
+ try:
+ registry = _get_provider_registry(
+ provider=provider,
+ settings=settings,
+ timeout_ms=settings.provider_timeout_ms,
+ )
+ except ValueError as e:
+ logger.warning("Provider configuration error: %s", e)
+ raise HTTPException(
+ status_code=400,
+ detail=str(e),
+ ) from e
+
+ # Check if any model is available before attempting generation
+ if not registry.is_available:
+ logger.warning("No models available for provider: %s", provider)
+ detail_msg = (
+ f"No {provider} models are currently available. Try again later."
+ )
+ raise HTTPException(
+ status_code=503,
+ detail=detail_msg,
+ headers={"Retry-After": "60"},
+ )
+
+ # =====================================================================
+ # Step 6: Generate response with fallback handling
+ # =====================================================================
+ try:
+ llm_response: LLMResponse = await registry.generate(llm_request)
+ except Exception as e:
+ # Check for AllModelsExhaustedError (either Gemini or Groq variant)
+ error_name = type(e).__name__
+ if "AllModelsExhaustedError" in error_name:
+ # Extract retry_after from the error if available
+ retry_after = getattr(e, "min_retry_after", None)
+ retry_header = str(retry_after) if retry_after else "60"
+
+ logger.warning(
+ "All %s models exhausted, retry_after=%s",
+ provider,
+ retry_header,
+ )
+
+ raise HTTPException(
+ status_code=503,
+ detail=(
+ f"All {provider} models are currently rate limited. "
+ f"Please retry after {retry_header} seconds."
+ ),
+ headers={"Retry-After": retry_header},
+ ) from e
+
+ # Unexpected error - log and return 500
+ logger.exception("LLM generation failed with unexpected error")
+ raise HTTPException(
+ status_code=500,
+ detail=f"LLM generation failed: {e}",
+ ) from e
+
+ # =====================================================================
+ # Step 7: Build and return response
+ # =====================================================================
+ # Calculate total latency and build the response model.
+ # =====================================================================
+ end_time = time.perf_counter()
+ latency_ms = int((end_time - start_time) * 1000)
+
+ # Build source references from retrieval results
+ sources = _build_source_references(retrieval_results)
+
+ # Build response using the response model
+ query_response = response_model(
+ query=query_text,
+ response=llm_response.content,
+ sources=sources,
+ provider=llm_response.provider,
+ model=llm_response.model,
+ latency_ms=latency_ms,
+ )
+
+ logger.info(
+ "Query completed: provider=%s, model=%s, latency=%dms, sources=%d",
+ llm_response.provider,
+ llm_response.model,
+ latency_ms,
+ len(sources),
+ )
+
+ # =====================================================================
+ # Step 8: Log query to HuggingFace (fire-and-forget)
+ # =====================================================================
+ # Log the query-response pair for analytics. This is non-blocking
+ # and uses asyncio.create_task() to avoid delaying the response.
+ # =====================================================================
+ try:
+ from rag_chatbot.qlog.service import get_query_log_service
+
+ # Format sources as "filename:pN" strings
+ source_strings = [
+ f"{result.source}:p{result.page or 0}" for result in retrieval_results
+ ]
+
+ service = get_query_log_service()
+ if service.is_running:
+ # Create background task for logging (fire-and-forget)
+ # Task is fire-and-forget: we intentionally don't await or track it
+ # The QueryLogService handles its own error logging internally
+ import asyncio
+
+ asyncio.create_task( # noqa: RUF006
+ service.log_query(
+ question=query_text,
+ answer=llm_response.content,
+ sources=source_strings,
+ model_name=llm_response.model,
+ )
+ )
+ except Exception:
+ logger.exception("Failed to log query")
+ # Don't re-raise - logging should never break the app
+
+ # Return as JSONResponse with the model's dict representation
+ return JSONResponse(
+ status_code=200,
+ content=query_response.model_dump(),
+ )
+
+ return router
+
+
+# =============================================================================
+# Module-level Router Instance
+# =============================================================================
+# The router is created lazily on first access using __getattr__.
+# This avoids importing FastAPI when the module is first loaded.
+
+_router: APIRouter | None = None
+
+
+def __getattr__(name: str) -> object:
+ """Lazy load module attributes.
+
+ This function is called when an attribute is not found in the module's
+ namespace. It's used to defer router creation until first access.
+
+ Args:
+ ----
+ name: The name of the attribute being accessed.
+
+ Returns:
+ -------
+ object: The requested attribute.
+
+ Raises:
+ ------
+ AttributeError: If the attribute is not found.
+
+ """
+ global _router # noqa: PLW0603
+
+ if name == "router":
+ if _router is None:
+ _router = _create_router()
+ return _router
+
+ msg = f"module {__name__!r} has no attribute {name!r}"
+ raise AttributeError(msg)
diff --git a/src/rag_chatbot/api/sse.py b/src/rag_chatbot/api/sse.py
new file mode 100644
index 0000000000000000000000000000000000000000..741ec12e5504bf7d8ae3d7a6be4e280c21a70304
--- /dev/null
+++ b/src/rag_chatbot/api/sse.py
@@ -0,0 +1,1020 @@
+r"""Server-Sent Events (SSE) streaming utilities for LLM responses.
+
+This module provides SSE event models and streaming utilities for real-time
+delivery of LLM-generated responses to clients. SSE is a standard web
+technology that enables servers to push data to clients over HTTP.
+
+Components:
+ - TokenEvent: Pydantic model for individual token events
+ - DoneEvent: Pydantic model for completion events with full metadata
+ - ErrorEvent: Pydantic model for error events
+ - stream_sse_response: Async generator for SSE-formatted responses
+
+SSE Event Format:
+ Token events stream individual chunks as they're generated:
+ data: {"type": "token", "content": "chunk of text"}\n\n
+
+ Done events signal completion with full response and metadata:
+ data: {"type": "done", "response": "full text", "sources": [...], ...}\n\n
+
+ Error events communicate failures to the client:
+ data: {"type": "error", "message": "error message", "retry_after": 60}\n\n
+
+Lazy Loading:
+ FastAPI and Pydantic dependencies are loaded on first use to avoid
+ import overhead for CLI tools that don't need the web server.
+
+Design Principles:
+ - Async-first: All streaming operations are async generators
+ - Type-safe: Full type annotations compatible with mypy strict mode
+ - Client disconnect handling: Detects when clients disconnect mid-stream
+ - Graceful error handling: Errors are communicated via error events
+
+Example:
+-------
+ >>> from rag_chatbot.api.sse import stream_sse_response, SourceInfo
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>> from rag_chatbot.llm.registry import ProviderRegistry
+ >>>
+ >>> # Create sources from retrieval results
+ >>> sources = [
+ ... SourceInfo(
+ ... chunk_id="chunk_001",
+ ... text="PMV stands for...",
+ ... source="ashrae_55.pdf",
+ ... page=5,
+ ... heading_path=["Thermal Comfort"],
+ ... score=0.85,
+ ... )
+ ... ]
+ >>>
+ >>> # Stream response
+ >>> async for event in stream_sse_response(registry, request, sources):
+ ... yield event # Send to client via StreamingResponse
+
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from collections.abc import AsyncIterator, Sequence
+from typing import TYPE_CHECKING, Any, Literal
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
+
+if TYPE_CHECKING:
+ from ..llm.base import LLMRequest
+ from ..llm.groq_registry import GroqProviderRegistry
+ from ..llm.registry import ProviderRegistry
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "TokenEvent",
+ "DoneEvent",
+ "ErrorEvent",
+ "SourceInfo",
+ "stream_sse_response",
+ "format_sse_event",
+]
+
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# SSE event prefix for data lines
+_SSE_DATA_PREFIX: str = "data: "
+
+# SSE event terminator (two newlines)
+_SSE_EVENT_TERMINATOR: str = "\n\n"
+
+# Event type constants
+EVENT_TYPE_TOKEN: Literal["token"] = "token"
+EVENT_TYPE_DONE: Literal["done"] = "done"
+EVENT_TYPE_ERROR: Literal["error"] = "error"
+
+# Default retry delay for rate limit errors (seconds)
+DEFAULT_RETRY_AFTER: int = 60
+
+
+# =============================================================================
+# Pydantic Model Factories (Lazy Loading)
+# =============================================================================
+# These factory functions create Pydantic models lazily to avoid importing
+# Pydantic at module load time. This follows the project's lazy loading pattern.
+# =============================================================================
+
+
+def _create_source_info_model() -> type:
+ """Create the SourceInfo Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time.
+
+ Returns
+ -------
+ type: The SourceInfo model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class _SourceInfo(BaseModel):
+ """Information about a source chunk used in generating the response.
+
+ This model represents a single chunk from the document store that
+ was retrieved as context for generating the LLM response. It is
+ included in DoneEvent to provide source attribution.
+
+ Attributes
+ ----------
+ chunk_id : str
+ Unique identifier for the chunk.
+ text : str
+ The text content of the chunk.
+ source : str
+ Original filename the chunk was extracted from.
+ page : int
+ Page number in the source document (1-indexed).
+ heading_path : list[str]
+ Hierarchical path of headings leading to this chunk.
+ score : float
+ Relevance score from the retriever (0.0 to 1.0).
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "chunk_id": "chunk_001",
+ "text": "The PMV model calculates thermal comfort...",
+ "source": "ashrae_55.pdf",
+ "page": 15,
+ "heading_path": ["Thermal Comfort Models", "PMV-PPD"],
+ "score": 0.85,
+ }
+ ]
+ },
+ )
+
+ chunk_id: str = Field(
+ ...,
+ min_length=1,
+ description="Unique identifier for the chunk",
+ )
+ text: str = Field(
+ ...,
+ min_length=1,
+ description="The text content of the chunk",
+ )
+ source: str = Field(
+ ...,
+ min_length=1,
+ description="Original filename the chunk was extracted from",
+ )
+ page: int = Field(
+ ...,
+ ge=1,
+ description="Page number in the source document (1-indexed)",
+ )
+ heading_path: list[str] = Field(
+ default_factory=list,
+ description="Hierarchical path of headings leading to this chunk",
+ )
+ score: float = Field(
+ ...,
+ ge=0.0,
+ le=1.0,
+ description="Relevance score from the retriever (0.0 to 1.0)",
+ )
+
+ return _SourceInfo
+
+
+def _create_token_event_model() -> type:
+ """Create the TokenEvent Pydantic model.
+
+ Returns
+ -------
+ type: The TokenEvent model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class _TokenEvent(BaseModel):
+ """SSE event for streaming a single token/chunk of generated text.
+
+ Token events are sent as the LLM generates text, allowing clients
+ to display the response in real-time as it's being generated.
+
+ Attributes:
+ ----------
+ type: Always "token" for this event type.
+ content: The text chunk to append to the response.
+
+ Example:
+ -------
+ >>> event = TokenEvent(content="Hello")
+ >>> event.model_dump_json()
+ '{"type":"token","content":"Hello"}'
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ json_schema_extra={
+ "examples": [
+ {"type": "token", "content": "The PMV model"},
+ {"type": "token", "content": " is used for"},
+ ]
+ },
+ )
+
+ type: Literal["token"] = Field(
+ default="token",
+ description="Event type identifier (always 'token')",
+ )
+ content: str = Field(
+ ...,
+ description="The text chunk to append to the response",
+ )
+
+ return _TokenEvent
+
+
+def _create_done_event_model(source_info_class: type) -> type:
+ """Create the DoneEvent Pydantic model.
+
+ Args:
+ ----
+ source_info_class: The SourceInfo model class for typing sources.
+
+ Returns:
+ -------
+ type: The DoneEvent model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class _DoneEvent(BaseModel):
+ """SSE event signaling completion of the response stream.
+
+ Done events are sent after all token events have been streamed.
+ They include the complete response text, source citations, and
+ metadata about the generation process.
+
+ Attributes:
+ ----------
+ type: Always "done" for this event type.
+ response: The complete generated response text.
+ sources: List of source chunks used as context.
+ provider: The LLM provider that generated the response.
+ model: The specific model used for generation.
+ latency_ms: Total response generation time in milliseconds.
+
+ Example:
+ -------
+ >>> event = DoneEvent(
+ ... response="The PMV model is...",
+ ... sources=[...],
+ ... provider="gemini",
+ ... model="gemini-2.5-flash-lite",
+ ... latency_ms=1250,
+ ... )
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "type": "done",
+ "response": "The PMV (Predicted Mean Vote) model is...",
+ "sources": [
+ {
+ "chunk_id": "chunk_001",
+ "text": "The PMV model calculates...",
+ "source": "ashrae_55.pdf",
+ "page": 15,
+ "heading_path": ["Thermal Comfort"],
+ "score": 0.85,
+ }
+ ],
+ "provider": "gemini",
+ "model": "gemini-2.5-flash-lite",
+ "latency_ms": 1250,
+ }
+ ]
+ },
+ )
+
+ type: Literal["done"] = Field(
+ default="done",
+ description="Event type identifier (always 'done')",
+ )
+ response: str = Field(
+ ...,
+ description="The complete generated response text",
+ )
+ sources: list[source_info_class] = Field( # type: ignore[valid-type]
+ default_factory=list,
+ description="List of source chunks used as context",
+ )
+ provider: str = Field(
+ ...,
+ min_length=1,
+ description="The LLM provider that generated the response",
+ )
+ model: str = Field(
+ ...,
+ min_length=1,
+ description="The specific model used for generation",
+ )
+ latency_ms: int = Field(
+ ...,
+ ge=0,
+ description="Total response generation time in milliseconds",
+ )
+
+ return _DoneEvent
+
+
+def _create_error_event_model() -> type:
+ """Create the ErrorEvent Pydantic model.
+
+ Returns
+ -------
+ type: The ErrorEvent model class.
+
+ """
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class _ErrorEvent(BaseModel):
+ """SSE event signaling an error during response generation.
+
+ Error events are sent when something goes wrong during streaming.
+ They provide information about the error and optionally suggest
+ when the client should retry.
+
+ Attributes:
+ ----------
+ type: Always "error" for this event type.
+ message: Human-readable error message.
+ retry_after: Optional seconds to wait before retrying.
+
+ Example:
+ -------
+ >>> event = ErrorEvent(
+ ... message="All models are rate limited",
+ ... retry_after=60,
+ ... )
+
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "type": "error",
+ "message": "All models are rate limited",
+ "retry_after": 60,
+ },
+ {
+ "type": "error",
+ "message": "An unexpected error occurred",
+ "retry_after": None,
+ },
+ ]
+ },
+ )
+
+ type: Literal["error"] = Field(
+ default="error",
+ description="Event type identifier (always 'error')",
+ )
+ message: str = Field(
+ ...,
+ min_length=1,
+ description="Human-readable error message",
+ )
+ retry_after: int | None = Field(
+ default=None,
+ ge=0,
+ description="Optional seconds to wait before retrying",
+ )
+
+ return _ErrorEvent
+
+
+# =============================================================================
+# Model Class Caches
+# =============================================================================
+# These module-level variables cache the lazily-created Pydantic model classes.
+# =============================================================================
+
+_source_info_model: type | None = None
+_token_event_model: type | None = None
+_done_event_model: type | None = None
+_error_event_model: type | None = None
+
+
+def _get_source_info() -> type:
+ """Get or create the SourceInfo model class.
+
+ Returns
+ -------
+ type: The SourceInfo Pydantic model class.
+
+ """
+ global _source_info_model # noqa: PLW0603
+ if _source_info_model is None:
+ _source_info_model = _create_source_info_model()
+ return _source_info_model
+
+
+def _get_token_event() -> type:
+ """Get or create the TokenEvent model class.
+
+ Returns
+ -------
+ type: The TokenEvent Pydantic model class.
+
+ """
+ global _token_event_model # noqa: PLW0603
+ if _token_event_model is None:
+ _token_event_model = _create_token_event_model()
+ return _token_event_model
+
+
+def _get_done_event() -> type:
+ """Get or create the DoneEvent model class.
+
+ Returns
+ -------
+ type: The DoneEvent Pydantic model class.
+
+ """
+ global _done_event_model # noqa: PLW0603
+ if _done_event_model is None:
+ source_info = _get_source_info()
+ _done_event_model = _create_done_event_model(source_info)
+ return _done_event_model
+
+
+def _get_error_event() -> type:
+ """Get or create the ErrorEvent model class.
+
+ Returns
+ -------
+ type: The ErrorEvent Pydantic model class.
+
+ """
+ global _error_event_model # noqa: PLW0603
+ if _error_event_model is None:
+ _error_event_model = _create_error_event_model()
+ return _error_event_model
+
+
+# =============================================================================
+# Public Model Classes (Lazy Proxies)
+# =============================================================================
+# These classes act as proxies that defer model creation until first use.
+# This enables lazy loading while maintaining the appearance of regular classes.
+# =============================================================================
+
+
+class SourceInfo:
+ """Information about a source chunk used in generating the response.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
+ ----------
+ chunk_id : str
+ Unique identifier for the chunk.
+ text : str
+ The text content of the chunk.
+ source : str
+ Original filename the chunk was extracted from.
+ page : int
+ Page number in the source document (1-indexed).
+ heading_path : list[str]
+ Hierarchical path of headings leading to this chunk.
+ score : float
+ Relevance score from the retriever (0.0 to 1.0).
+
+ """
+
+ def __new__(cls, **kwargs: object) -> SourceInfo:
+ """Create a new SourceInfo instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model.
+
+ Returns:
+ -------
+ SourceInfo: A SourceInfo Pydantic model instance.
+
+ """
+ model_class = _get_source_info()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> SourceInfo:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ SourceInfo: Validated SourceInfo instance.
+
+ """
+ model_class = _get_source_info()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+ @classmethod
+ def from_retrieval_result(cls, result: object) -> SourceInfo:
+ """Create a SourceInfo from a RetrievalResult.
+
+ Convenience factory method to convert retrieval results to source info.
+
+ Args:
+ ----
+ result: A RetrievalResult object with matching attributes.
+
+ Returns:
+ -------
+ SourceInfo: New SourceInfo instance with data from the result.
+
+ Example:
+ -------
+ >>> from rag_chatbot.retrieval.models import RetrievalResult
+ >>> result = RetrievalResult(
+ ... chunk_id="chunk_001",
+ ... text="The PMV model...",
+ ... score=0.85,
+ ... source="ashrae_55.pdf",
+ ... page=5,
+ ... heading_path=["Thermal Comfort"],
+ ... )
+ >>> source = SourceInfo.from_retrieval_result(result)
+
+ """
+ model_class = _get_source_info()
+ # Extract attributes dynamically from the result object
+ chunk_id = result.chunk_id # type: ignore[attr-defined]
+ text = result.text # type: ignore[attr-defined]
+ source = result.source # type: ignore[attr-defined]
+ page = result.page # type: ignore[attr-defined]
+ heading_path = getattr(result, "heading_path", [])
+ score = result.score # type: ignore[attr-defined]
+ instance: SourceInfo = model_class(
+ chunk_id=chunk_id,
+ text=text,
+ source=source,
+ page=page,
+ heading_path=heading_path,
+ score=score,
+ )
+ return instance
+
+
+class TokenEvent:
+ """SSE event for streaming a single token/chunk of generated text.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
+ ----------
+ type : str
+ Always "token" for this event type.
+ content : str
+ The text chunk to append to the response.
+
+ """
+
+ def __new__(cls, **kwargs: object) -> TokenEvent:
+ """Create a new TokenEvent instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model.
+
+ Returns:
+ -------
+ TokenEvent: A TokenEvent Pydantic model instance.
+
+ """
+ model_class = _get_token_event()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> TokenEvent:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ TokenEvent: Validated TokenEvent instance.
+
+ """
+ model_class = _get_token_event()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+
+class DoneEvent:
+ """SSE event signaling completion of the response stream.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
+ ----------
+ type : str
+ Always "done" for this event type.
+ response : str
+ The complete generated response text.
+ sources : list[SourceInfo]
+ List of source chunks used as context.
+ provider : str
+ The LLM provider that generated the response.
+ model : str
+ The specific model used for generation.
+ latency_ms : int
+ Total response generation time in milliseconds.
+
+ """
+
+ def __new__(cls, **kwargs: object) -> DoneEvent:
+ """Create a new DoneEvent instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model.
+
+ Returns:
+ -------
+ DoneEvent: A DoneEvent Pydantic model instance.
+
+ """
+ model_class = _get_done_event()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> DoneEvent:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ DoneEvent: Validated DoneEvent instance.
+
+ """
+ model_class = _get_done_event()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+
+class ErrorEvent:
+ """SSE event signaling an error during response generation.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ Attributes
+ ----------
+ type : str
+ Always "error" for this event type.
+ message : str
+ Human-readable error message.
+ retry_after : int | None
+ Optional seconds to wait before retrying.
+
+ """
+
+ def __new__(cls, **kwargs: object) -> ErrorEvent:
+ """Create a new ErrorEvent instance.
+
+ Args:
+ ----
+ **kwargs: Field values for the model.
+
+ Returns:
+ -------
+ ErrorEvent: An ErrorEvent Pydantic model instance.
+
+ """
+ model_class = _get_error_event()
+ return model_class(**kwargs) # type: ignore[no-any-return]
+
+ @classmethod
+ def model_validate(cls, obj: object) -> ErrorEvent:
+ """Validate and create a model from an object.
+
+ Args:
+ ----
+ obj: Object to validate.
+
+ Returns:
+ -------
+ ErrorEvent: Validated ErrorEvent instance.
+
+ """
+ model_class = _get_error_event()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+
+# =============================================================================
+# SSE Formatting Utilities
+# =============================================================================
+
+
+def format_sse_event(event: object) -> str:
+ r"""Format a Pydantic model as an SSE event string.
+
+ Converts a Pydantic model to JSON and wraps it in the SSE format
+ with the "data:" prefix and double newline terminator.
+
+ Args:
+ ----
+ event: A Pydantic model instance with a model_dump_json() method.
+
+ Returns:
+ -------
+ str: SSE-formatted event string ready to send to the client.
+
+ Example:
+ -------
+ >>> event = TokenEvent(content="Hello")
+ >>> format_sse_event(event)
+ 'data: {"type":"token","content":"Hello"}\n\n'
+
+ Note:
+ ----
+ The returned string includes the trailing double newline required
+ by the SSE specification to delimit events.
+
+ """
+ # Get JSON representation from Pydantic model
+ json_str = event.model_dump_json() # type: ignore[attr-defined]
+ return f"{_SSE_DATA_PREFIX}{json_str}{_SSE_EVENT_TERMINATOR}"
+
+
+def _format_dict_as_sse(data: dict[str, Any]) -> str:
+ """Format a dictionary as an SSE event string.
+
+ Internal helper that formats a raw dictionary as SSE without
+ requiring a Pydantic model.
+
+ Args:
+ ----
+ data: Dictionary to serialize as JSON.
+
+ Returns:
+ -------
+ str: SSE-formatted event string.
+
+ """
+ json_str = json.dumps(data, ensure_ascii=False)
+ return f"{_SSE_DATA_PREFIX}{json_str}{_SSE_EVENT_TERMINATOR}"
+
+
+# =============================================================================
+# SSE Streaming Generator
+# =============================================================================
+
+
+async def stream_sse_response( # noqa: PLR0915
+ registry: ProviderRegistry | GroqProviderRegistry,
+ request: LLMRequest,
+ sources: Sequence[object],
+ preferred_model: str | None = None,
+) -> AsyncIterator[str]:
+ """Stream LLM response as SSE events.
+
+ This async generator streams the LLM response in real-time using
+ Server-Sent Events format. It:
+ 1. Yields token events as chunks are generated
+ 2. Tracks the accumulated response
+ 3. On completion, yields a done event with full metadata
+ 4. On error, yields an error event with retry information
+
+ Args:
+ ----
+ registry: The ProviderRegistry or GroqProviderRegistry to use for LLM
+ generation. Must have a stream() method that yields string chunks.
+ request: The LLMRequest containing query, context, and parameters.
+ sources: List of source objects (RetrievalResult or SourceInfo) to
+ include in the done event. These provide source attribution.
+ preferred_model: Optional preferred model name to use. If specified
+ and available, it will be tried first.
+
+ Yields:
+ ------
+ str: SSE-formatted event strings in the following sequence:
+ - Zero or more token events with content chunks
+ - One done event with complete response and metadata
+ OR
+ - One error event if an error occurs
+
+ Example:
+ -------
+ >>> from fastapi.responses import StreamingResponse
+ >>>
+ >>> async def query_endpoint(request: QueryRequest):
+ ... # Get sources from retriever
+ ... sources = retriever.retrieve(request.query)
+ ...
+ ... # Build LLM request
+ ... llm_request = LLMRequest(
+ ... query=request.query,
+ ... context=[s.text for s in sources],
+ ... )
+ ...
+ ... # Stream response
+ ... return StreamingResponse(
+ ... stream_sse_response(registry, llm_request, sources),
+ ... media_type="text/event-stream",
+ ... )
+
+ Note:
+ ----
+ The generator handles client disconnects gracefully by checking
+ for GeneratorExit exceptions. If the client disconnects, the
+ generator stops yielding events.
+
+ Rate limit errors (AllModelsExhaustedError) are converted to
+ error events with retry_after information.
+
+ """
+ # Import here to avoid circular imports and enable lazy loading
+ # Import both error types to handle Gemini and Groq providers
+ from ..llm.groq_registry import GroqAllModelsExhaustedError
+ from ..llm.registry import AllModelsExhaustedError
+
+ # Record start time for latency calculation
+ start_time = time.perf_counter()
+
+ # Track accumulated response
+ accumulated_response: list[str] = []
+
+ # Track provider and model info (will be extracted from response)
+ provider_name: str = "unknown"
+ model_name: str = "unknown"
+
+ try:
+ # Get the first available model to determine provider/model info
+ available_model = registry.get_available_model(preferred=preferred_model)
+ if available_model is not None:
+ provider_name = available_model.provider_name
+ model_name = available_model.model_name
+
+ # Stream tokens from the registry
+ async for chunk in registry.stream(request, preferred_model=preferred_model):
+ # Accumulate the response
+ accumulated_response.append(chunk)
+
+ # Create and yield token event
+ token_event = TokenEvent(content=chunk)
+ yield format_sse_event(token_event)
+
+ # Calculate latency
+ end_time = time.perf_counter()
+ latency_ms = int((end_time - start_time) * 1000)
+
+ # Build complete response
+ complete_response = "".join(accumulated_response)
+
+ # Convert sources to SourceInfo models
+ source_infos: list[object] = []
+ source_info_class = _get_source_info()
+ for source in sources:
+ # Check if source is already a SourceInfo instance
+ if isinstance(source, source_info_class):
+ source_infos.append(source)
+ else:
+ # Try to convert from RetrievalResult or similar object
+ try:
+ # Extract attributes directly
+ chunk_id = source.chunk_id # type: ignore[attr-defined]
+ text = source.text # type: ignore[attr-defined]
+ src = source.source # type: ignore[attr-defined]
+ page = source.page # type: ignore[attr-defined]
+ heading_path = getattr(source, "heading_path", [])
+ score = source.score # type: ignore[attr-defined]
+ source_info = source_info_class(
+ chunk_id=chunk_id,
+ text=text,
+ source=src,
+ page=page,
+ heading_path=heading_path,
+ score=score,
+ )
+ source_infos.append(source_info)
+ except (AttributeError, TypeError):
+ # Skip sources that can't be converted
+ continue
+
+ # Create and yield done event
+ done_event = DoneEvent(
+ response=complete_response,
+ sources=source_infos,
+ provider=provider_name,
+ model=model_name,
+ latency_ms=latency_ms,
+ )
+ yield format_sse_event(done_event)
+
+ # =====================================================================
+ # Log query to HuggingFace (fire-and-forget)
+ # =====================================================================
+ # Log the query-response pair for analytics. This is non-blocking
+ # and uses asyncio.create_task() to avoid delaying the response.
+ # =====================================================================
+ try:
+ from rag_chatbot.qlog.service import get_query_log_service
+
+ # Format sources as "filename:pN" strings
+ source_strings = [
+ f"{getattr(source, 'source', 'unknown')}:p{getattr(source, 'page', 0)}"
+ for source in sources
+ ]
+
+ service = get_query_log_service()
+ if service.is_running:
+ # Create background task for logging (fire-and-forget)
+ # Task is fire-and-forget: we intentionally don't await or track it
+ # The QueryLogService handles its own error logging internally
+ import asyncio
+
+ asyncio.create_task( # noqa: RUF006
+ service.log_query(
+ question=request.query,
+ answer=complete_response,
+ sources=source_strings,
+ model_name=model_name,
+ )
+ )
+ except Exception:
+ # Import logging here to maintain lazy loading
+ import logging
+
+ logging.getLogger(__name__).exception("Failed to log query")
+ # Don't re-raise - logging should never break the app
+
+ except (AllModelsExhaustedError, GroqAllModelsExhaustedError) as e:
+ # All models are rate limited - send error event with retry info
+ # Both error types have min_retry_after attribute
+ retry_after = e.min_retry_after if e.min_retry_after else DEFAULT_RETRY_AFTER
+ error_event = ErrorEvent(
+ message=str(e),
+ retry_after=retry_after,
+ )
+ yield format_sse_event(error_event)
+
+ except GeneratorExit:
+ # Client disconnected - stop gracefully without yielding
+ # This is normal behavior when clients close the connection
+ return
+
+ except TimeoutError as e:
+ # Timeout error - send error event
+ error_event = ErrorEvent(
+ message=f"Request timed out: {e}",
+ retry_after=None,
+ )
+ yield format_sse_event(error_event)
+
+ except Exception as e:
+ # Unexpected error - send generic error event
+ error_event = ErrorEvent(
+ message=f"An error occurred: {type(e).__name__}: {e}",
+ retry_after=None,
+ )
+ yield format_sse_event(error_event)
diff --git a/src/rag_chatbot/config/settings.py b/src/rag_chatbot/config/settings.py
index cedae065ad45f9311a4bbee1557854f034f8bd08..74becc1e92f4d659547e8b81b033e0d934abf2b1 100644
--- a/src/rag_chatbot/config/settings.py
+++ b/src/rag_chatbot/config/settings.py
@@ -158,6 +158,19 @@ def _create_settings_class() -> type:
),
]
+ anthropic_api_key: Annotated[
+ str | None,
+ Field(
+ default=None,
+ alias="ANTHROPIC_API_KEY",
+ description=(
+ "API key for Anthropic LLM provider. "
+ "Used as tertiary fallback with Claude Haiku. "
+ "Get from: https://console.anthropic.com/"
+ ),
+ ),
+ ]
+
# =====================================================================
# HuggingFace Configuration
# =====================================================================
@@ -443,6 +456,48 @@ class Settings:
"""
+ # =========================================================================
+ # Type Stubs for mypy
+ # =========================================================================
+ # These are type annotations for the dynamically created Settings class.
+ # They enable mypy to understand the attributes without runtime overhead.
+ # The actual implementation is in _create_settings_class().
+ # =========================================================================
+
+ # LLM Provider API Keys
+ gemini_api_key: str | None
+ deepseek_api_key: str | None
+ groq_api_key: str | None
+ anthropic_api_key: str | None
+
+ # HuggingFace Configuration
+ hf_token: str | None
+ hf_index_repo: str
+ hf_qlog_repo: str
+
+ # Application Settings
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+ environment: Literal["development", "staging", "production"]
+ cors_origins_str: str
+ cors_origins: list[str]
+
+ # Retrieval Settings
+ top_k: int
+ use_reranker: bool
+ use_hybrid: bool
+
+ # Rate Limiting Settings
+ default_cooldown_seconds: int
+ provider_timeout_ms: int
+
+ # Artifact Caching Settings
+ artifact_cache_path: str
+ force_artifact_refresh: bool
+
+ # Query Logging Settings
+ qlog_batch_size: int
+ qlog_flush_interval_seconds: int
+
def __new__(cls) -> Settings:
"""Create a new Settings instance using lazy-loaded class.
diff --git a/src/rag_chatbot/embeddings/encoder.py b/src/rag_chatbot/embeddings/encoder.py
index 06c6d37cc3a8b830db51bf10a6a0ea5f953c7a25..df8b3f07284c00ee3a8bc55d0cd7bd3bb1889295 100644
--- a/src/rag_chatbot/embeddings/encoder.py
+++ b/src/rag_chatbot/embeddings/encoder.py
@@ -208,7 +208,7 @@ class BGEEncoder:
# torch (500MB+) and sentence-transformers at module import time.
# This is crucial for fast startup in the serve pipeline.
# =================================================================
- import torch # type: ignore[import-not-found]
+ import torch
from sentence_transformers import SentenceTransformer
# =================================================================
diff --git a/src/rag_chatbot/extraction/pdf_extractor.py b/src/rag_chatbot/extraction/pdf_extractor.py
index 2e8151d7714e4ba6af5e874326232ad95de34cd4..301572b1b021763d4510977252c1a1169431eca2 100644
--- a/src/rag_chatbot/extraction/pdf_extractor.py
+++ b/src/rag_chatbot/extraction/pdf_extractor.py
@@ -865,7 +865,7 @@ class PDFExtractor:
else:
return True
- def _extract_box_content(self, box: object) -> list[str]:
+ def _extract_box_content(self, box: object) -> list[str]: # noqa: PLR0912
"""Extract text content from a layout box.
This helper method extracts text content from a LayoutBox object
diff --git a/src/rag_chatbot/llm/__init__.py b/src/rag_chatbot/llm/__init__.py
index 5b296907493bdf7e4dafd386391a59a20286b24d..036a94122e05218accda8c97ce0061f4bcc90b16 100644
--- a/src/rag_chatbot/llm/__init__.py
+++ b/src/rag_chatbot/llm/__init__.py
@@ -10,6 +10,9 @@ The module implements a provider registry pattern with automatic
fallback when a provider fails or exceeds quota.
Components:
+ - LLMRequest: Pydantic model for LLM generation requests
+ - LLMResponse: Pydantic model for LLM generation responses
+ - LLMProvider: Protocol defining the provider interface
- BaseLLM: Abstract base class for LLM providers
- GeminiLLM: Gemini provider implementation
- GroqLLM: Groq provider implementation
@@ -23,9 +26,10 @@ Lazy Loading:
Example:
-------
- >>> from rag_chatbot.llm import GeminiLLM
+ >>> from rag_chatbot.llm import LLMRequest, GeminiLLM
>>> llm = GeminiLLM(api_key="...")
- >>> response = await llm.generate("What is PMV?", context=[...])
+ >>> request = LLMRequest(query="What is PMV?", context=[...])
+ >>> response = await llm.generate(request)
"""
@@ -34,21 +38,51 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
- from .base import BaseLLM
+ from .base import BaseLLM, LLMProvider, LLMRequest, LLMResponse
from .deepseek import DeepSeekLLM
from .gemini import GeminiLLM
- from .groq import GroqLLM
- from .quota import QuotaManager
+ from .groq import GroqLLM, GroqRateLimitError
+ from .groq_quota import (
+ GroqModelQuota,
+ GroqModelStatus,
+ GroqModelUsage,
+ GroqQuotaManager,
+ )
+ from .groq_registry import (
+ GroqAllModelsExhaustedError,
+ GroqProviderRegistry,
+ )
+ from .quota import ModelQuota, ModelStatus, ModelUsage, QuotaManager
+ from .registry import AllModelsExhaustedError, ProviderRegistry
# =============================================================================
# Module Exports
# =============================================================================
__all__: list[str] = [
+ # Base exports
+ "LLMRequest",
+ "LLMResponse",
+ "LLMProvider",
"BaseLLM",
+ # Provider implementations
"GeminiLLM",
"GroqLLM",
+ "GroqRateLimitError",
"DeepSeekLLM",
+ # Google/Gemini quota and registry
"QuotaManager",
+ "ModelQuota",
+ "ModelUsage",
+ "ModelStatus",
+ "ProviderRegistry",
+ "AllModelsExhaustedError",
+ # Groq quota and registry
+ "GroqQuotaManager",
+ "GroqModelQuota",
+ "GroqModelUsage",
+ "GroqModelStatus",
+ "GroqProviderRegistry",
+ "GroqAllModelsExhaustedError",
]
@@ -71,10 +105,13 @@ def __getattr__(name: str) -> object:
AttributeError: If the attribute is not a valid export.
"""
- if name == "BaseLLM":
- from .base import BaseLLM
+ # Base module exports (LLMRequest, LLMResponse, LLMProvider, BaseLLM)
+ if name in {"LLMRequest", "LLMResponse", "LLMProvider", "BaseLLM"}:
+ from . import base
- return BaseLLM
+ return getattr(base, name)
+
+ # Provider implementations
if name == "GeminiLLM":
from .gemini import GeminiLLM
@@ -83,6 +120,10 @@ def __getattr__(name: str) -> object:
from .groq import GroqLLM
return GroqLLM
+ if name == "GroqRateLimitError":
+ from .groq import GroqRateLimitError
+
+ return GroqRateLimitError
if name == "DeepSeekLLM":
from .deepseek import DeepSeekLLM
@@ -91,5 +132,54 @@ def __getattr__(name: str) -> object:
from .quota import QuotaManager
return QuotaManager
+ if name == "ModelQuota":
+ from .quota import ModelQuota
+
+ return ModelQuota
+ if name == "ModelUsage":
+ from .quota import ModelUsage
+
+ return ModelUsage
+ if name == "ModelStatus":
+ from .quota import ModelStatus
+
+ return ModelStatus
+ if name == "ProviderRegistry":
+ from .registry import ProviderRegistry
+
+ return ProviderRegistry
+ if name == "AllModelsExhaustedError":
+ from .registry import AllModelsExhaustedError
+
+ return AllModelsExhaustedError
+
+ # Groq quota manager exports
+ if name == "GroqQuotaManager":
+ from .groq_quota import GroqQuotaManager
+
+ return GroqQuotaManager
+ if name == "GroqModelQuota":
+ from .groq_quota import GroqModelQuota
+
+ return GroqModelQuota
+ if name == "GroqModelUsage":
+ from .groq_quota import GroqModelUsage
+
+ return GroqModelUsage
+ if name == "GroqModelStatus":
+ from .groq_quota import GroqModelStatus
+
+ return GroqModelStatus
+
+ # Groq provider registry exports
+ if name == "GroqProviderRegistry":
+ from .groq_registry import GroqProviderRegistry
+
+ return GroqProviderRegistry
+ if name == "GroqAllModelsExhaustedError":
+ from .groq_registry import GroqAllModelsExhaustedError
+
+ return GroqAllModelsExhaustedError
+
msg = f"module {__name__!r} has no attribute {name!r}" # pragma: no cover
raise AttributeError(msg) # pragma: no cover
diff --git a/src/rag_chatbot/llm/base.py b/src/rag_chatbot/llm/base.py
index f6f83c0408e3ed053e6d46f20fbb89075ca64b83..611696d06b778dfa62a5af0ca63937a3128fab78 100644
--- a/src/rag_chatbot/llm/base.py
+++ b/src/rag_chatbot/llm/base.py
@@ -1,19 +1,39 @@
-"""Base class for LLM provider implementations.
+"""Base classes and interfaces for LLM provider implementations.
-This module defines the BaseLLM abstract base class that all LLM
-provider implementations must inherit from. The base class defines
-the common interface for:
- - Async text generation
- - Streaming response support
- - Error handling and retries
- - Usage tracking
+This module defines the core interfaces and data models that all LLM
+provider implementations must adhere to. The design follows a protocol-based
+approach for flexibility and testability.
+
+Components:
+ - LLMRequest: Pydantic model for LLM generation requests
+ - LLMResponse: Pydantic model for LLM generation responses
+ - LLMProvider: Protocol defining the provider interface
+ - BaseLLM: Abstract base class with common functionality
+
+Design Principles:
+ - Protocol-based interface enables dependency injection and testing
+ - Pydantic v2 for validation and serialization
+ - Async-first design for non-blocking LLM calls
+ - Lazy loading pattern for heavy dependencies
Lazy Loading:
- No heavy dependencies - this module loads quickly.
+ No heavy dependencies (torch, transformers, etc.) are imported at module
+ level. Provider-specific SDKs are loaded on first use.
-Note:
-----
- This is a placeholder that will be fully implemented in Step 3.1.
+Example:
+-------
+ >>> from rag_chatbot.llm.base import LLMRequest, LLMResponse, BaseLLM
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... max_tokens=1024,
+ ... temperature=0.7,
+ ... )
+ >>> # Concrete implementations inherit from BaseLLM
+ >>> class MyLLM(BaseLLM):
+ ... async def generate(self, request: LLMRequest) -> LLMResponse:
+ ... # Implementation here
+ ... ...
"""
@@ -21,89 +41,783 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ field_validator,
+)
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
if TYPE_CHECKING:
- from collections.abc import Sequence
+ pass # No type-only imports needed currently
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["BaseLLM", "LLMResponse"]
+__all__: list[str] = [
+ "ChatMessage",
+ "LLMRequest",
+ "LLMResponse",
+ "LLMProvider",
+ "BaseLLM",
+]
-class LLMResponse: # pragma: no cover
- """Represents a response from an LLM provider.
+# =============================================================================
+# Data Models
+# =============================================================================
+
+
+class ChatMessage(BaseModel):
+ """A single message in the conversation history.
+
+ This model represents one message in a multi-turn conversation,
+ capturing the speaker's role and the message content. It is used
+ to pass conversation history to LLM providers for context-aware
+ responses.
+
+ The model is frozen (immutable) to ensure thread-safety when
+ conversation history is shared across requests.
Attributes:
----------
- content: The generated text content.
- provider: Name of the provider that generated the response.
- model: Model identifier used for generation.
- tokens_used: Number of tokens consumed.
- finish_reason: Reason for generation completion.
+ role : Literal["user", "assistant"]
+ The speaker's role in the conversation.
+ - "user": Messages from the human user
+ - "assistant": Messages from the AI assistant
+
+ content : str
+ The text content of the message.
+ Must be a non-empty string after stripping whitespace.
+
+ Example:
+ -------
+ >>> from rag_chatbot.llm.base import ChatMessage
+ >>> user_msg = ChatMessage(role="user", content="What is PMV?")
+ >>> assistant_msg = ChatMessage(
+ ... role="assistant",
+ ... content="PMV stands for Predicted Mean Vote..."
+ ... )
+ >>> # Use in conversation history
+ >>> history = [user_msg, assistant_msg]
Note:
----
- This class will be converted to a Pydantic model in Step 3.1.
+ The role is restricted to "user" and "assistant" only.
+ System messages are handled separately by providers and are
+ not part of the conversation history.
"""
- def __init__( # noqa: PLR0913
- self,
- content: str,
- provider: str,
- model: str,
- tokens_used: int,
- finish_reason: str,
- ) -> None:
- """Initialize an LLM response.
+ # -------------------------------------------------------------------------
+ # Model Configuration
+ # -------------------------------------------------------------------------
+ model_config = ConfigDict(
+ # Forbid extra fields to catch typos and ensure data integrity
+ extra="forbid",
+ # Make the model immutable for thread-safety
+ frozen=True,
+ # Allow population by field name or alias
+ populate_by_name=True,
+ # Validate default values during model creation
+ validate_default=True,
+ # Enable JSON schema generation with examples
+ json_schema_extra={
+ "examples": [
+ {
+ "role": "user",
+ "content": "What is PMV?",
+ },
+ {
+ "role": "assistant",
+ "content": "PMV stands for Predicted Mean Vote...",
+ },
+ ]
+ },
+ )
+
+ # -------------------------------------------------------------------------
+ # Fields
+ # -------------------------------------------------------------------------
+
+ role: Literal["user", "assistant"] = Field(
+ ..., # Required field (no default)
+ description="The speaker's role in the conversation (user or assistant)",
+ examples=["user", "assistant"],
+ )
+
+ content: str = Field(
+ ..., # Required field (no default)
+ min_length=1, # Must not be empty after validation
+ description="The text content of the message",
+ examples=["What is PMV?", "PMV stands for Predicted Mean Vote..."],
+ )
+
+ # -------------------------------------------------------------------------
+ # Validators
+ # -------------------------------------------------------------------------
+
+ @field_validator("content", mode="before")
+ @classmethod
+ def _strip_content(cls, value: object) -> str:
+ """Strip whitespace from content and validate non-empty.
+
+ Args:
+ ----
+ value: The input value to process.
+
+ Returns:
+ -------
+ Stripped content string.
+
+ Raises:
+ ------
+ ValueError: If value is None or empty after stripping.
+
+ """
+ if value is None:
+ msg = "content cannot be None"
+ raise ValueError(msg)
+
+ result = str(value).strip()
+
+ if not result:
+ msg = "content cannot be empty"
+ raise ValueError(msg)
+
+ return result
+
+
+class LLMRequest(BaseModel):
+ """Request model for LLM text generation.
+
+ Encapsulates all parameters needed for an LLM generation request,
+ including the user query, retrieved context chunks, and generation
+ parameters. This model provides type-safe, validated input for
+ all LLM provider implementations.
+
+ The model is frozen (immutable) to ensure thread-safety when the same
+ request is processed by multiple providers in a fallback chain.
+
+ Attributes:
+ ----------
+ query : str
+ The user's question or prompt to answer.
+ Must be a non-empty string after stripping whitespace.
+
+ context : list[str]
+ List of retrieved context chunks to include in the prompt.
+ These chunks provide relevant information from the knowledge
+ base for the LLM to reference when generating a response.
+ Can be empty if no context is available.
+
+ max_tokens : int
+ Maximum number of tokens to generate in the response.
+ Must be a positive integer. Default is 1024.
+ Higher values allow longer responses but increase latency
+ and cost.
+
+ temperature : float
+ Sampling temperature controlling response randomness.
+ Must be between 0.0 and 2.0 inclusive.
+ - 0.0: Deterministic, most likely tokens selected
+ - 0.7: Balanced creativity and coherence (default)
+ - 2.0: Maximum randomness, more creative but less coherent
+
+ history : list[ChatMessage]
+ Previous conversation messages for multi-turn context.
+ Messages should alternate between user and assistant roles.
+ Can be empty for single-turn requests (default).
+ Limited to prevent context window overflow (typically 10 messages).
+
+ Example:
+ -------
+ >>> from rag_chatbot.llm.base import LLMRequest, ChatMessage
+ >>> # Single-turn request (no history)
+ >>> request = LLMRequest(
+ ... query="What is the PMV model?",
+ ... context=[
+ ... "PMV stands for Predicted Mean Vote...",
+ ... "The PMV model was developed by Fanger...",
+ ... ],
+ ... max_tokens=512,
+ ... temperature=0.5,
+ ... )
+ >>> request.query
+ 'What is the PMV model?'
+ >>> len(request.context)
+ 2
+ >>>
+ >>> # Multi-turn request with conversation history
+ >>> history = [
+ ... ChatMessage(role="user", content="What is PMV?"),
+ ... ChatMessage(role="assistant", content="PMV stands for..."),
+ ... ]
+ >>> request_with_history = LLMRequest(
+ ... query="How do I calculate it?",
+ ... context=["To calculate PMV, use pythermalcomfort..."],
+ ... history=history,
+ ... )
+ >>> len(request_with_history.history)
+ 2
+
+ Note:
+ ----
+ The context list order matters - more relevant chunks should
+ appear first as some LLMs may truncate context if it exceeds
+ their context window.
+
+ For multi-turn conversations, the history list should contain
+ the previous exchanges in chronological order. The current
+ query is NOT included in history - it is passed separately.
+
+ """
+
+ # -------------------------------------------------------------------------
+ # Model Configuration
+ # -------------------------------------------------------------------------
+ model_config = ConfigDict(
+ # Forbid extra fields to catch typos and ensure data integrity
+ extra="forbid",
+ # Make the model immutable for thread-safety
+ frozen=True,
+ # Allow population by field name or alias
+ populate_by_name=True,
+ # Validate default values during model creation
+ validate_default=True,
+ # Enable JSON schema generation with examples
+ json_schema_extra={
+ "examples": [
+ {
+ "query": "What is PMV?",
+ "context": [
+ "PMV stands for Predicted Mean Vote...",
+ "The PMV model predicts thermal sensation...",
+ ],
+ "max_tokens": 1024,
+ "temperature": 0.7,
+ },
+ {
+ "query": "How to calculate thermal comfort?",
+ "context": ["Thermal comfort can be calculated using..."],
+ "max_tokens": 512,
+ "temperature": 0.5,
+ },
+ ]
+ },
+ )
+
+ # -------------------------------------------------------------------------
+ # Fields
+ # -------------------------------------------------------------------------
+
+ query: str = Field(
+ ..., # Required field (no default)
+ min_length=1, # Must not be empty after validation
+ description="The user's question or prompt to answer",
+ examples=["What is PMV?", "How do I calculate thermal comfort?"],
+ )
+
+ context: list[str] = Field(
+ default_factory=list,
+ description="List of retrieved context chunks to include in the prompt",
+ examples=[
+ ["PMV stands for Predicted Mean Vote...", "The PMV model was developed..."],
+ [],
+ ],
+ )
+
+ max_tokens: int = Field(
+ default=1024,
+ gt=0, # Must be positive (greater than 0)
+ description="Maximum number of tokens to generate in the response",
+ examples=[256, 512, 1024, 2048],
+ )
+
+ temperature: float = Field(
+ default=0.7,
+ ge=0.0, # Minimum temperature is 0.0
+ le=2.0, # Maximum temperature is 2.0
+ description="Sampling temperature controlling response randomness (0.0-2.0)",
+ examples=[0.0, 0.5, 0.7, 1.0, 1.5, 2.0],
+ )
+
+ history: list[ChatMessage] = Field(
+ default_factory=list,
+ description=(
+ "Previous conversation messages for multi-turn context. "
+ "Messages should alternate between user and assistant roles. "
+ "Limited to prevent context window overflow."
+ ),
+ examples=[
+ [],
+ [
+ {"role": "user", "content": "What is PMV?"},
+ {"role": "assistant", "content": "PMV stands for..."},
+ ],
+ ],
+ )
+
+ # -------------------------------------------------------------------------
+ # Validators
+ # -------------------------------------------------------------------------
+
+ @field_validator("query", mode="before")
+ @classmethod
+ def _strip_query(cls, value: object) -> str:
+ """Strip whitespace from query and validate non-empty.
+
+ Args:
+ ----
+ value: The input value to process.
+
+ Returns:
+ -------
+ Stripped query string.
+
+ Raises:
+ ------
+ ValueError: If value is None or empty after stripping.
+
+ """
+ if value is None:
+ msg = "query cannot be None"
+ raise ValueError(msg)
+
+ result = str(value).strip()
+
+ if not result:
+ msg = "query cannot be empty"
+ raise ValueError(msg)
+
+ return result
+
+ @field_validator("context", mode="before")
+ @classmethod
+ def _ensure_context_list(cls, value: object) -> list[str]:
+ """Ensure context is always a list of non-empty strings.
+
+ Args:
+ ----
+ value: The input value to normalize.
+
+ Returns:
+ -------
+ List of context strings (may be empty).
+
+ """
+ if value is None:
+ return []
+
+ if isinstance(value, str):
+ # Single context chunk provided as string
+ stripped = value.strip()
+ return [stripped] if stripped else []
+
+ if isinstance(value, list):
+ # Filter out empty strings and strip whitespace
+ return [str(chunk).strip() for chunk in value if str(chunk).strip()]
+
+ # Handle other iterables
+ try:
+ iterator = iter(value) # type: ignore[call-overload]
+ return [str(chunk).strip() for chunk in iterator if str(chunk).strip()]
+ except TypeError:
+ # Not iterable, wrap in list if non-empty
+ chunk_str = str(value).strip()
+ return [chunk_str] if chunk_str else []
+
+
+class LLMResponse(BaseModel):
+ """Response model for LLM text generation.
+
+ Encapsulates the generated response along with metadata about the
+ generation process. This model provides a consistent interface for
+ all LLM provider responses, enabling provider-agnostic handling.
+
+ The model is frozen (immutable) to ensure thread-safety when responses
+ are cached or processed by multiple consumers.
+
+ Attributes:
+ ----------
+ content : str
+ The generated text content from the LLM.
+ Must be a non-empty string after stripping whitespace.
+
+ provider : str
+ Name of the provider that generated the response.
+ Used for logging, analytics, and debugging.
+ Examples: "gemini", "deepseek", "anthropic"
+
+ model : str
+ Specific model identifier used for generation.
+ Used for logging and reproducibility.
+ Examples: "gemini-1.5-flash", "deepseek-chat", "claude-3-haiku"
+
+ tokens_used : int
+ Total number of tokens consumed (input + output).
+ Must be non-negative. Used for usage tracking and cost estimation.
+
+ latency_ms : int
+ Time taken for generation in milliseconds.
+ Must be non-negative. Used for performance monitoring.
+
+ Example:
+ -------
+ >>> response = LLMResponse(
+ ... content="PMV stands for Predicted Mean Vote...",
+ ... provider="gemini",
+ ... model="gemini-1.5-flash",
+ ... tokens_used=256,
+ ... latency_ms=450,
+ ... )
+ >>> response.provider
+ 'gemini'
+ >>> response.latency_ms
+ 450
+
+ Note:
+ ----
+ The tokens_used field represents total tokens (prompt + completion).
+ For detailed token breakdown, provider-specific implementations may
+ track input/output tokens separately.
+
+ """
+
+ # -------------------------------------------------------------------------
+ # Model Configuration
+ # -------------------------------------------------------------------------
+ model_config = ConfigDict(
+ # Forbid extra fields to catch typos and ensure data integrity
+ extra="forbid",
+ # Make the model immutable for thread-safety
+ frozen=True,
+ # Allow population by field name or alias
+ populate_by_name=True,
+ # Validate default values during model creation
+ validate_default=True,
+ # Enable JSON schema generation with examples
+ json_schema_extra={
+ "examples": [
+ {
+ "content": "PMV stands for Predicted Mean Vote...",
+ "provider": "gemini",
+ "model": "gemini-1.5-flash",
+ "tokens_used": 256,
+ "latency_ms": 450,
+ },
+ {
+ "content": "Thermal comfort can be calculated...",
+ "provider": "deepseek",
+ "model": "deepseek-chat",
+ "tokens_used": 512,
+ "latency_ms": 800,
+ },
+ ]
+ },
+ )
+
+ # -------------------------------------------------------------------------
+ # Fields
+ # -------------------------------------------------------------------------
+
+ content: str = Field(
+ ..., # Required field (no default)
+ min_length=1, # Must not be empty after validation
+ description="The generated text content from the LLM",
+ examples=["PMV stands for Predicted Mean Vote...", "Thermal comfort is..."],
+ )
+
+ provider: str = Field(
+ ..., # Required field
+ min_length=1, # Must not be empty
+ description="Name of the provider that generated the response",
+ examples=["gemini", "deepseek", "anthropic", "groq"],
+ )
+
+ model: str = Field(
+ ..., # Required field
+ min_length=1, # Must not be empty
+ description="Specific model identifier used for generation",
+ examples=["gemini-1.5-flash", "deepseek-chat", "claude-3-haiku"],
+ )
+
+ tokens_used: int = Field(
+ ..., # Required field
+ ge=0, # Must be non-negative
+ description="Total number of tokens consumed (input + output)",
+ examples=[128, 256, 512, 1024],
+ )
+
+ latency_ms: int = Field(
+ ..., # Required field
+ ge=0, # Must be non-negative
+ description="Time taken for generation in milliseconds",
+ examples=[100, 250, 500, 1000],
+ )
+
+ # -------------------------------------------------------------------------
+ # Validators
+ # -------------------------------------------------------------------------
+
+ @field_validator("content", mode="before")
+ @classmethod
+ def _strip_content(cls, value: object) -> str:
+ """Strip whitespace from content and validate non-empty.
+
+ Args:
+ ----
+ value: The input value to process.
+
+ Returns:
+ -------
+ Stripped content string.
+
+ Raises:
+ ------
+ ValueError: If value is None or empty after stripping.
+
+ """
+ if value is None:
+ msg = "content cannot be None"
+ raise ValueError(msg)
+
+ result = str(value).strip()
+
+ if not result:
+ msg = "content cannot be empty"
+ raise ValueError(msg)
+
+ return result
+
+ @field_validator("provider", "model", mode="before")
+ @classmethod
+ def _strip_string_fields(cls, value: object) -> str:
+ """Strip whitespace from string identifier fields.
Args:
----
- content: The generated text content.
- provider: Name of the provider.
- model: Model identifier used.
- tokens_used: Number of tokens consumed.
- finish_reason: Reason for completion.
+ value: The input value to process.
+
+ Returns:
+ -------
+ Stripped string value.
Raises:
------
- NotImplementedError: LLMResponse will be implemented in Step 3.1.
+ ValueError: If value is None or empty after stripping.
"""
- raise NotImplementedError("LLMResponse will be implemented in Step 3.1")
+ if value is None:
+ msg = "Field cannot be None"
+ raise ValueError(msg)
+
+ result = str(value).strip()
+
+ if not result:
+ msg = "Field cannot be empty"
+ raise ValueError(msg)
+
+ return result
+
+
+# =============================================================================
+# Protocols
+# =============================================================================
+
+
+@runtime_checkable
+class LLMProvider(Protocol):
+ """Protocol defining the LLM provider interface.
+
+ This protocol specifies the contract that all LLM provider implementations
+ must follow. It enables dependency injection, testing with mock providers,
+ and interchangeable provider implementations.
+
+ Any class implementing this protocol can be used wherever an LLMProvider
+ is expected, regardless of the underlying LLM service (Gemini, DeepSeek,
+ Anthropic, etc.).
+ Methods:
+ -------
+ generate: Async method for complete text generation.
+ stream: Async method for streaming text generation.
+
+ Example:
+ -------
+ >>> class MockLLM:
+ ... async def generate(self, request: LLMRequest) -> LLMResponse:
+ ... return LLMResponse(
+ ... content="Mock response",
+ ... provider="mock",
+ ... model="mock-1.0",
+ ... tokens_used=10,
+ ... latency_ms=1,
+ ... )
+ ... async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
+ ... yield "Mock "
+ ... yield "response"
+ >>> provider: LLMProvider = MockLLM()
+ >>> isinstance(provider, LLMProvider)
+ True
+
+ Note:
+ ----
+ The @runtime_checkable decorator allows isinstance() checks at
+ runtime, which is useful for validating provider implementations
+ in factory functions and dependency injection containers.
+
+ """
-class BaseLLM(ABC): # pragma: no cover
+ async def generate(self, request: LLMRequest) -> LLMResponse:
+ """Generate a complete response for the given request.
+
+ This method sends the request to the LLM and waits for the complete
+ response. It is suitable for use cases where streaming is not needed
+ or when the full response is required before proceeding.
+
+ Args:
+ ----
+ request: The LLM request containing query, context, and parameters.
+
+ Returns:
+ -------
+ LLMResponse with generated content and metadata.
+
+ Raises:
+ ------
+ RuntimeError: If the LLM API call fails.
+ TimeoutError: If the request exceeds the configured timeout.
+
+ Example:
+ -------
+ >>> request = LLMRequest(query="What is PMV?", context=[...])
+ >>> response = await provider.generate(request)
+ >>> print(response.content)
+ 'PMV stands for Predicted Mean Vote...'
+
+ """
+ ...
+
+ async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
+ """Stream a response for the given request.
+
+ This method sends the request to the LLM and yields response chunks
+ as they are generated. It is suitable for real-time display of
+ responses in chat interfaces.
+
+ Args:
+ ----
+ request: The LLM request containing query, context, and parameters.
+
+ Yields:
+ ------
+ String chunks of the generated response as they become available.
+
+ Raises:
+ ------
+ RuntimeError: If the LLM API call fails.
+ TimeoutError: If the request exceeds the configured timeout.
+
+ Example:
+ -------
+ >>> request = LLMRequest(query="What is PMV?", context=[...])
+ >>> async for chunk in provider.stream(request):
+ ... print(chunk, end="", flush=True)
+ PMV stands for Predicted Mean Vote...
+
+ Note:
+ ----
+ The stream method does not return an LLMResponse. To get metadata
+ like tokens_used, callers should track the accumulated response
+ or use the generate() method instead.
+
+ """
+ ...
+
+
+# =============================================================================
+# Abstract Base Class
+# =============================================================================
+
+
+class BaseLLM(ABC):
"""Abstract base class for LLM provider implementations.
- This class defines the common interface that all LLM providers
- must implement. It provides:
- - Async generation interface
- - Streaming support
- - Common error handling
- - Usage tracking hooks
+ This class provides common functionality for all LLM providers,
+ including configuration storage, health checking, and availability
+ tracking. Concrete implementations must override the generate and
+ stream methods to provide provider-specific API integration.
- Subclasses must implement the generate and stream methods for
- their specific provider API.
+ The class follows the lazy loading pattern - no heavy dependencies
+ are imported until methods are called. Provider-specific SDKs are
+ loaded in concrete subclasses.
Attributes:
----------
- provider_name: Name identifier for this provider.
- model_name: Model identifier being used.
- timeout_ms: Request timeout in milliseconds.
+ provider_name : str
+ Name identifier for this provider (e.g., "gemini", "deepseek").
+ Used for logging and response metadata.
+
+ model_name : str
+ Model identifier to use (e.g., "gemini-1.5-flash").
+ Used for API calls and response metadata.
+
+ timeout_ms : int
+ Request timeout in milliseconds. Default is 30000 (30 seconds).
+ Requests exceeding this timeout will raise TimeoutError.
+
+ Properties:
+ ----------
+ is_available : bool
+ Whether this provider is currently available for use.
+ Checks API key configuration and basic connectivity.
+
+ Methods:
+ -------
+ generate: Abstract method for complete text generation.
+ stream: Abstract method for streaming text generation.
+ check_health: Check if the provider is healthy and responsive.
Example:
-------
- >>> class MyLLM(BaseLLM):
- ... async def generate(self, prompt, context):
+ >>> class GeminiLLM(BaseLLM):
+ ... def __init__(self, api_key: str):
+ ... super().__init__("gemini", "gemini-1.5-flash")
+ ... self._api_key = api_key
+ ...
+ ... @property
+ ... def is_available(self) -> bool:
+ ... return bool(self._api_key)
+ ...
+ ... async def check_health(self) -> bool:
+ ... # Implementation here
+ ... ...
+ ...
+ ... async def generate(self, request: LLMRequest) -> LLMResponse:
... # Implementation here
- ... pass
+ ... ...
+ ...
+ ... async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
+ ... # Implementation here
+ ... ...
Note:
----
- This class will be fully implemented in Phase 3 (Step 3.1).
+ Subclasses must implement:
+ - is_available property
+ - check_health() method
+ - generate() method
+ - stream() method
"""
@@ -113,86 +827,202 @@ class BaseLLM(ABC): # pragma: no cover
model_name: str,
timeout_ms: int = 30000,
) -> None:
- """Initialize the base LLM.
+ """Initialize the base LLM with configuration.
Args:
----
provider_name: Name identifier for this provider.
- model_name: Model identifier to use.
- timeout_ms: Request timeout in milliseconds.
+ model_name: Model identifier to use for API calls.
+ timeout_ms: Request timeout in milliseconds. Default is 30000.
Raises:
------
- NotImplementedError: BaseLLM will be implemented in Step 3.1.
+ ValueError: If provider_name or model_name is empty.
+ ValueError: If timeout_ms is not positive.
+
+ Example:
+ -------
+ >>> class MyLLM(BaseLLM):
+ ... def __init__(self):
+ ... super().__init__("my_provider", "my-model-v1", timeout_ms=60000)
"""
- self._provider_name = provider_name
- self._model_name = model_name
+ # Validate provider_name
+ if not provider_name or not provider_name.strip():
+ msg = "provider_name cannot be empty"
+ raise ValueError(msg)
+
+ # Validate model_name
+ if not model_name or not model_name.strip():
+ msg = "model_name cannot be empty"
+ raise ValueError(msg)
+
+ # Validate timeout_ms
+ if timeout_ms <= 0:
+ msg = "timeout_ms must be positive"
+ raise ValueError(msg)
+
+ self._provider_name = provider_name.strip()
+ self._model_name = model_name.strip()
self._timeout_ms = timeout_ms
- raise NotImplementedError("BaseLLM will be implemented in Step 3.1")
@property
def provider_name(self) -> str:
- """Get the provider name.
+ """Get the provider name identifier.
Returns
-------
- String identifier for this provider.
+ String identifier for this provider (e.g., "gemini").
"""
return self._provider_name
@property
def model_name(self) -> str:
- """Get the model name.
+ """Get the model name identifier.
Returns
-------
- String identifier for the model being used.
+ String identifier for the model being used (e.g., "gemini-1.5-flash").
"""
return self._model_name
+ @property
+ def timeout_ms(self) -> int:
+ """Get the request timeout in milliseconds.
+
+ Returns
+ -------
+ Timeout value in milliseconds.
+
+ """
+ return self._timeout_ms
+
+ @property
@abstractmethod
- async def generate(
- self,
- prompt: str,
- context: Sequence[str],
- system_prompt: str | None = None,
- ) -> LLMResponse:
- """Generate a response for the given prompt.
+ def is_available(self) -> bool:
+ """Check if this provider is available for use.
+
+ This property should check whether the provider is properly
+ configured and ready to accept requests. Typical checks include:
+ - API key is set and valid format
+ - Required environment variables are present
+ - No known service outages
+
+ This property should be fast and not make network calls.
+ For thorough health checking, use check_health() instead.
+
+ Returns:
+ -------
+ True if the provider is available, False otherwise.
+
+ Example:
+ -------
+ >>> if llm.is_available:
+ ... response = await llm.generate(request)
+ ... else:
+ ... # Use fallback provider
+ ... ...
+
+ """
+ ...
+
+ @abstractmethod
+ async def check_health(self) -> bool:
+ """Check if the provider is healthy and responsive.
+
+ This method performs a lightweight health check to verify the
+ provider is functioning correctly. Unlike is_available, this
+ method may make a network call to verify connectivity.
+
+ The health check should be quick (ideally under 5 seconds) and
+ should not consume significant resources or API quota.
+
+ Returns:
+ -------
+ True if the provider is healthy and responsive, False otherwise.
+
+ Example:
+ -------
+ >>> if await llm.check_health():
+ ... print("Provider is healthy")
+ ... else:
+ ... print("Provider health check failed")
+
+ Note:
+ ----
+ Implementations should catch and handle exceptions internally,
+ returning False for any failures rather than propagating errors.
+
+ """
+ ...
+
+ @abstractmethod
+ async def generate(self, request: LLMRequest) -> LLMResponse:
+ """Generate a complete response for the given request.
+
+ This method sends the request to the LLM provider and waits for
+ the complete response. It should handle API-specific formatting,
+ error handling, and response parsing.
Args:
----
- prompt: User query or prompt.
- context: Retrieved context chunks to include.
- system_prompt: Optional system prompt override.
+ request: The LLM request containing query, context, and parameters.
Returns:
-------
LLMResponse with generated content and metadata.
+ Raises:
+ ------
+ RuntimeError: If the LLM API call fails.
+ TimeoutError: If the request exceeds the configured timeout.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for..."],
+ ... max_tokens=512,
+ ... )
+ >>> response = await llm.generate(request)
+ >>> print(response.content)
+
"""
...
@abstractmethod
- async def stream(
- self,
- prompt: str,
- context: Sequence[str],
- system_prompt: str | None = None,
- ) -> AsyncIterator[str]:
- """Stream a response for the given prompt.
+ async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
+ """Stream a response for the given request.
+
+ This method sends the request to the LLM provider and yields
+ response chunks as they are generated. It enables real-time
+ display of responses in chat interfaces.
Args:
----
- prompt: User query or prompt.
- context: Retrieved context chunks to include.
- system_prompt: Optional system prompt override.
+ request: The LLM request containing query, context, and parameters.
Yields:
------
- String chunks of the generated response.
+ String chunks of the generated response as they become available.
+
+ Raises:
+ ------
+ RuntimeError: If the LLM API call fails.
+ TimeoutError: If the request exceeds the configured timeout.
+
+ Example:
+ -------
+ >>> request = LLMRequest(query="What is PMV?", context=[...])
+ >>> async for chunk in llm.stream(request):
+ ... print(chunk, end="", flush=True)
+
+ Note:
+ ----
+ Implementations should use AsyncIterator typing and yield
+ chunks as they become available from the API.
"""
...
diff --git a/src/rag_chatbot/llm/gemini.py b/src/rag_chatbot/llm/gemini.py
index 5517dae14bf91a07cf9636a47c69699239d30660..7231a19788cec456d6d9d91de569ecddae8016d6 100644
--- a/src/rag_chatbot/llm/gemini.py
+++ b/src/rag_chatbot/llm/gemini.py
@@ -1,141 +1,856 @@
"""Gemini LLM provider implementation.
This module provides the GeminiLLM class for integrating with Google's
-Gemini API. Gemini is the primary provider in the fallback chain due to:
- - High quality responses
- - Good rate limits
- - Streaming support
+Gemini API. Gemini is the primary provider in the fallback chain due to
+its high quality responses, competitive rate limits, and robust streaming
+support.
+
+Components:
+ - GeminiLLM: Main provider class implementing BaseLLM interface
+ - RateLimitError: Exception raised when API returns 429 status
Lazy Loading:
- google-generativeai is loaded on first use to avoid import overhead.
+ The google-generativeai SDK is loaded on first API call, not at import
+ time. This follows the project's lazy loading pattern to minimize startup
+ overhead and memory usage when the provider is not actively used.
+
+Design Principles:
+ - Async-first: All API calls are wrapped with asyncio.to_thread()
+ - Type-safe: Full type annotations compatible with mypy strict mode
+ - Error handling: Graceful handling of rate limits, timeouts, and errors
+ - Testable: Dependency injection friendly with mockable SDK loading
+
+Example:
+-------
+ >>> from rag_chatbot.llm.gemini import GeminiLLM, RateLimitError
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Initialize with API key
+ >>> llm = GeminiLLM(api_key="your-api-key")
+ >>>
+ >>> # Check availability
+ >>> if llm.is_available:
+ ... request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... )
+ ... response = await llm.generate(request)
+ ... print(response.content)
Note:
----
- This is a placeholder that will be fully implemented in Step 3.2.
+ Requires the google-generativeai package to be installed:
+ `pip install google-generativeai`
"""
from __future__ import annotations
+import asyncio
+import contextlib
+import time
from collections.abc import AsyncIterator
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
+
+from .base import BaseLLM, LLMRequest, LLMResponse
+from .prompts import MAX_HISTORY_MESSAGES, THERMAL_COMFORT_SYSTEM_PROMPT
+
+# =============================================================================
+# Constants
+# =============================================================================
+# HTTP status code for rate limiting
+_HTTP_429_RATE_LIMIT: int = 429
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
if TYPE_CHECKING:
- from collections.abc import Sequence
+ import google.generativeai as genai # noqa: F401
- from .base import LLMResponse
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["GeminiLLM"]
+__all__: list[str] = ["GeminiLLM", "RateLimitError"]
+
+
+# =============================================================================
+# Lazy Loading Infrastructure
+# =============================================================================
+# The google.generativeai module is loaded on first use to avoid import
+# overhead at module load time. This is critical for fast server startup.
+# =============================================================================
+
+# Module-level cache for lazy-loaded google.generativeai module
+_genai_module: object | None = None
+
+
+def _get_genai() -> Any: # noqa: ANN401
+ """Lazy load google.generativeai on first use.
+
+ This function implements the lazy loading pattern required by the
+ project architecture. The google.generativeai SDK is only imported
+ when this function is called, typically during the first API call.
+
+ Returns:
+ -------
+ The google.generativeai module.
+
+ Raises:
+ ------
+ ImportError: If google-generativeai package is not installed.
+
+ Example:
+ -------
+ >>> genai = _get_genai()
+ >>> genai.configure(api_key="...")
+
+ Note:
+ ----
+ The module is cached globally after first import, so subsequent
+ calls return the cached reference without re-importing.
+
+ """
+ global _genai_module # noqa: PLW0603
+ if _genai_module is None:
+ import google.generativeai as genai
+
+ _genai_module = genai
+ return _genai_module
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
+
+
+class RateLimitError(Exception):
+ """Exception raised when the Gemini API returns a 429 rate limit error.
+ This exception is raised when the API indicates that the request rate
+ has exceeded the allowed limit. It includes information about when the
+ client should retry the request.
+
+ Attributes:
+ ----------
+ retry_after : int
+ The number of seconds to wait before retrying the request.
+ Defaults to 900 seconds (15 minutes) if not provided by the API.
-class GeminiLLM: # pragma: no cover
+ Example:
+ -------
+ >>> try:
+ ... response = await llm.generate(request)
+ ... except RateLimitError as e:
+ ... print(f"Rate limited. Retry after {e.retry_after} seconds")
+ ... await asyncio.sleep(e.retry_after)
+
+ Note:
+ ----
+ The retry_after value should be used to implement exponential
+ backoff or cooldown periods in the calling code.
+
+ """
+
+ def __init__(self, message: str, retry_after: int | None = None) -> None:
+ """Initialize RateLimitError with message and retry information.
+
+ Args:
+ ----
+ message: Human-readable error message describing the rate limit.
+ retry_after: Number of seconds to wait before retrying.
+ Defaults to 900 (15 minutes) if not provided.
+
+ Example:
+ -------
+ >>> raise RateLimitError("Rate limit exceeded", retry_after=30)
+ >>> raise RateLimitError("Too many requests") # Uses default 900s
+
+ """
+ super().__init__(message)
+ # Default to 15 minutes if no retry_after provided
+ self.retry_after: int = retry_after if retry_after is not None else 900
+
+
+# =============================================================================
+# GeminiLLM Provider Class
+# =============================================================================
+
+
+class GeminiLLM(BaseLLM):
"""Gemini LLM provider implementation.
- This class provides integration with Google's Gemini API for
- text generation. It implements the BaseLLM interface with
- support for:
- - Async generation
- - Streaming responses
- - Automatic retries
+ This class provides integration with Google's Gemini API for text
+ generation. It implements the BaseLLM interface with full support for:
+ - Async generation via asyncio.to_thread()
+ - Streaming responses via async iteration
+ - Rate limit handling with retry information
- Token usage tracking
+ - Latency measurement
- The default model is 'gemini-1.5-flash' which provides a good
- balance of quality, speed, and cost.
+ The default model is 'gemini-2.5-pro' which provides excellent quality
+ for RAG applications with good response times.
Attributes:
----------
- api_key: Gemini API key for authentication.
- model_name: Gemini model to use.
- timeout_ms: Request timeout in milliseconds.
+ provider_name : str
+ Always "gemini" for this provider.
+ model_name : str
+ The Gemini model to use (default: "gemini-2.5-pro").
+ timeout_ms : int
+ Request timeout in milliseconds (default: 30000).
Example:
-------
- >>> llm = GeminiLLM(api_key="...")
- >>> response = await llm.generate("What is PMV?", context=[...])
- >>> print(response.content)
+ >>> from rag_chatbot.llm.gemini import GeminiLLM
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Initialize with API key
+ >>> llm = GeminiLLM(api_key="your-api-key")
+ >>>
+ >>> # Check if provider is available
+ >>> if llm.is_available:
+ ... # Create a request
+ ... request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... max_tokens=512,
+ ... temperature=0.7,
+ ... )
+ ...
+ ... # Generate response
+ ... response = await llm.generate(request)
+ ... print(response.content)
+ ...
+ ... # Or stream the response
+ ... async for chunk in llm.stream(request):
+ ... print(chunk, end="", flush=True)
Note:
----
- This class will be fully implemented in Phase 3 (Step 3.2).
+ The Gemini SDK is lazily loaded on first API call to minimize
+ startup overhead. The API key is validated only for non-empty
+ format; actual authentication happens on first request.
"""
+ # Default configuration values
+ _DEFAULT_MODEL: str = "gemini-2.5-pro"
+ _DEFAULT_TIMEOUT_MS: int = 30000
+ _PROVIDER_NAME: str = "gemini"
+
def __init__(
self,
- api_key: str,
- model_name: str = "gemini-1.5-flash",
- timeout_ms: int = 30000,
+ api_key: str | None,
+ model: str = _DEFAULT_MODEL,
+ timeout_ms: int = _DEFAULT_TIMEOUT_MS,
) -> None:
"""Initialize the Gemini LLM provider.
Args:
----
- api_key: Gemini API key for authentication.
- model_name: Gemini model to use. Defaults to 'gemini-1.5-flash'.
- timeout_ms: Request timeout in milliseconds. Defaults to 30000.
+ api_key: Gemini API key for authentication. Must be a non-empty
+ string. Can be obtained from Google AI Studio.
+ model: Gemini model to use. Defaults to 'gemini-2.5-pro'.
+ Other options include 'gemini-1.5-flash' for faster responses.
+ timeout_ms: Request timeout in milliseconds. Defaults to 30000
+ (30 seconds). Increase for complex queries.
Raises:
------
- NotImplementedError: GeminiLLM will be implemented in Step 3.2.
+ ValueError: If api_key is None, empty, or whitespace-only.
+
+ Example:
+ -------
+ >>> # Basic initialization
+ >>> llm = GeminiLLM(api_key="your-api-key")
+ >>>
+ >>> # Custom model and timeout
+ >>> llm = GeminiLLM(
+ ... api_key="your-api-key",
+ ... model="gemini-1.5-flash",
+ ... timeout_ms=60000,
+ ... )
+
+ Note:
+ ----
+ The API key is stored but not validated until the first API call.
+ This allows for fast initialization even when the API is unavailable.
"""
- self._api_key = api_key
- self._model_name = model_name
- self._timeout_ms = timeout_ms
- raise NotImplementedError("GeminiLLM will be implemented in Step 3.2")
+ # Validate api_key is not empty
+ if api_key is None or not api_key.strip():
+ msg = "api_key cannot be empty"
+ raise ValueError(msg)
- async def generate(
- self,
- prompt: str,
- context: Sequence[str],
- system_prompt: str | None = None,
- ) -> LLMResponse:
- """Generate a response using Gemini.
+ # Initialize base class with provider configuration
+ super().__init__(
+ provider_name=self._PROVIDER_NAME,
+ model_name=model,
+ timeout_ms=timeout_ms,
+ )
+
+ # Store API key (stripped of whitespace)
+ self._api_key: str = api_key.strip()
+
+ # Track SDK configuration state
+ self._configured: bool = False
+
+ # Track rate limit state for monitoring
+ self._last_rate_limit_time: float = 0.0
+
+ @property
+ def is_available(self) -> bool:
+ """Check if this provider is available for use.
+
+ Returns True if the API key is set and non-empty. This is a fast
+ check that does not make network calls.
+
+ Returns:
+ -------
+ True if API key is configured, False otherwise.
+
+ Example:
+ -------
+ >>> llm = GeminiLLM(api_key="valid-key")
+ >>> llm.is_available
+ True
+ >>>
+ >>> # After clearing the key (for testing)
+ >>> llm._api_key = ""
+ >>> llm.is_available
+ False
+
+ Note:
+ ----
+ This property only checks if the API key is set. It does not
+ verify that the key is valid or that the API is reachable.
+ Use check_health() for thorough connectivity verification.
+
+ """
+ return bool(self._api_key)
+
+ def _ensure_configured(self) -> Any: # noqa: ANN401
+ """Ensure the Gemini SDK is loaded and configured.
+
+ This method implements the lazy loading pattern. It loads the
+ google.generativeai module and configures it with the API key
+ on first call. Subsequent calls return immediately.
+
+ Returns:
+ -------
+ The configured google.generativeai module.
+
+ Example:
+ -------
+ >>> # Called internally before any API operation
+ >>> genai = self._ensure_configured()
+ >>> model = genai.GenerativeModel(...)
+
+ Note:
+ ----
+ This method is idempotent - calling it multiple times is safe
+ and efficient. The SDK is only configured once per instance.
+
+ """
+ genai = _get_genai()
+
+ # Configure the SDK with API key if not already done
+ if not self._configured:
+ genai.configure(api_key=self._api_key)
+ self._configured = True
+
+ return genai
+
+ def _build_system_instruction(self, request: LLMRequest) -> str:
+ """Build the system instruction with context for the model.
+
+ Constructs a system instruction that includes the domain-specific
+ system prompt and any retrieved context chunks. This instruction
+ guides the model's behavior and provides grounding information.
Args:
----
- prompt: User query or prompt.
- context: Retrieved context chunks to include.
- system_prompt: Optional system prompt override.
+ request: The LLMRequest containing context chunks.
Returns:
-------
- LLMResponse with generated content and metadata.
+ Complete system instruction string for the GenerativeModel.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for...", "The model was developed..."],
+ ... )
+ >>> instruction = self._build_system_instruction(request)
+ >>> "pythermalcomfort Assistant" in instruction
+ True
+ >>> "PMV stands for" in instruction
+ True
+
+ Note:
+ ----
+ The system instruction includes the full THERMAL_COMFORT_SYSTEM_PROMPT
+ followed by the retrieved context chunks. This ensures the model
+ has both behavioral guidance and relevant information.
+
+ """
+ # Start with the base system prompt
+ instruction = THERMAL_COMFORT_SYSTEM_PROMPT
+
+ # Append context if provided
+ if request.context:
+ context_text = "\n\n".join(request.context)
+ instruction += f"\n\n**Retrieved Context:**\n{context_text}"
+
+ return instruction
+
+ def _build_chat_contents(
+ self, request: LLMRequest
+ ) -> list[dict[str, str | list[dict[str, str]]]]:
+ """Build Gemini chat contents with conversation history.
+
+ Constructs a list of Content objects for the Gemini chat API,
+ including any conversation history followed by the current query.
+ Gemini uses "user" and "model" roles (not "assistant").
+
+ Args:
+ ----
+ request: The LLMRequest containing query and optional history.
+
+ Returns:
+ -------
+ List of content dictionaries ready for the Gemini API.
+ Each dict has "role" (user/model) and "parts" (list of text dicts).
+
+ Example:
+ -------
+ >>> from rag_chatbot.llm.base import ChatMessage
+ >>> history = [
+ ... ChatMessage(role="user", content="Hello"),
+ ... ChatMessage(role="assistant", content="Hi there!"),
+ ... ]
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... history=history,
+ ... )
+ >>> contents = self._build_chat_contents(request)
+ >>> len(contents)
+ 3
+ >>> contents[0]["role"]
+ 'user'
+ >>> contents[1]["role"]
+ 'model'
+
+ Note:
+ ----
+ - History is limited to MAX_HISTORY_MESSAGES to prevent overflow
+ - "assistant" role is mapped to "model" for Gemini compatibility
+ - Each message is wrapped in the Gemini Content format
+
+ """
+ contents: list[dict[str, str | list[dict[str, str]]]] = []
+
+ # Add conversation history (limited to prevent context overflow)
+ # Take the most recent messages if history exceeds the limit
+ history_to_use = request.history[-MAX_HISTORY_MESSAGES:]
+
+ for msg in history_to_use:
+ # Map "assistant" to "model" for Gemini's role naming convention
+ gemini_role = "model" if msg.role == "assistant" else "user"
+ contents.append({
+ "role": gemini_role,
+ "parts": [{"text": msg.content}],
+ })
+
+ # Add the current query as the final user message
+ contents.append({
+ "role": "user",
+ "parts": [{"text": request.query}],
+ })
+
+ return contents
+
+ def _build_prompt(self, request: LLMRequest) -> str:
+ """Build a simple prompt string from an LLMRequest (legacy method).
+
+ This method is maintained for backward compatibility and simple
+ use cases. For multi-turn conversations, use _build_chat_contents()
+ with _build_system_instruction() instead.
+
+ Args:
+ ----
+ request: The LLMRequest containing query and context.
+
+ Returns:
+ -------
+ Formatted prompt string ready for the API.
+
+ Note:
+ ----
+ This method does NOT include conversation history. Use the
+ chat contents approach for multi-turn conversations.
+
+ """
+ # Build context section if context chunks are provided
+ if request.context:
+ context_text = "\n\n".join(request.context)
+ return f"Context:\n{context_text}\n\nQuestion: {request.query}"
+
+ # No context - just return the question
+ return f"Question: {request.query}"
+
+ def _handle_error(self, error: Exception) -> None:
+ """Handle API errors and convert to appropriate exceptions.
+
+ This method examines the error and raises the appropriate
+ exception type (RateLimitError, TimeoutError, or RuntimeError).
+
+ Args:
+ ----
+ error: The exception caught from the API call.
Raises:
------
- NotImplementedError: Method will be implemented in Step 3.2.
+ RateLimitError: If the error indicates a 429 rate limit.
+ TimeoutError: If the error indicates a timeout.
+ RuntimeError: For all other API errors.
+
+ Example:
+ -------
+ >>> try:
+ ... response = model.generate_content(...)
+ ... except Exception as e:
+ ... self._handle_error(e) # Will re-raise appropriately
+
+ Note:
+ ----
+ This method always raises an exception - it never returns normally.
"""
- raise NotImplementedError("generate() will be implemented in Step 3.2")
+ # Check for timeout errors
+ if isinstance(error, asyncio.TimeoutError):
+ raise TimeoutError(str(error)) from error
- async def stream(
- self,
- _prompt: str,
- _context: Sequence[str],
- _system_prompt: str | None = None,
+ # Check for rate limit (429) errors
+ # The Gemini SDK raises various exception types, so we check attributes
+ error_code = getattr(error, "code", None)
+ error_str = str(error).lower()
+
+ is_rate_limit = (
+ error_code == _HTTP_429_RATE_LIMIT
+ or "429" in error_str
+ or "resource exhausted" in error_str
+ or "rate limit" in error_str
+ )
+
+ if is_rate_limit:
+ # Record rate limit time for monitoring
+ self._last_rate_limit_time = time.time()
+
+ # Try to extract Retry-After header
+ retry_after: int | None = None
+ headers = getattr(error, "headers", None)
+ if headers and isinstance(headers, dict):
+ retry_value = headers.get("Retry-After")
+ if retry_value is not None:
+ with contextlib.suppress(ValueError, TypeError):
+ retry_after = int(retry_value)
+
+ raise RateLimitError(str(error), retry_after=retry_after) from error
+
+ # All other errors become RuntimeError
+ raise RuntimeError(str(error)) from error
+
+ async def check_health(self) -> bool:
+ """Check if the provider is healthy and responsive.
+
+ Makes a lightweight API call to verify that the Gemini API is
+ reachable and responding. This is useful for health checks and
+ load balancing decisions.
+
+ Returns:
+ -------
+ True if the API is healthy and responsive, False otherwise.
+
+ Example:
+ -------
+ >>> llm = GeminiLLM(api_key="valid-key")
+ >>> if await llm.check_health():
+ ... print("Gemini API is healthy")
+ ... else:
+ ... print("Gemini API health check failed")
+
+ Note:
+ ----
+ This method catches all exceptions and returns False for any
+ failure. It does not raise exceptions to ensure it can be used
+ safely in health check endpoints.
+
+ """
+ try:
+ genai = self._ensure_configured()
+
+ # Create a model and make a minimal request
+ model = genai.GenerativeModel(model_name=self._model_name)
+
+ # Run the synchronous call in a thread pool
+ response = await asyncio.to_thread(
+ model.generate_content,
+ "Say OK",
+ )
+
+ # Check that we got a valid response
+ return response is not None and hasattr(response, "text")
+
+ except Exception:
+ # Any error means the health check failed
+ return False
+
+ async def generate(self, request: LLMRequest) -> LLMResponse:
+ """Generate a complete response for the given request.
+
+ Sends the request to the Gemini API and waits for the complete
+ response. This method handles prompt construction, API calls,
+ error handling, and response parsing.
+
+ Args:
+ ----
+ request: The LLMRequest containing query, context, and parameters.
+ Must be a valid LLMRequest instance.
+
+ Returns:
+ -------
+ LLMResponse with generated content and metadata including
+ provider name, model, token count, and latency.
+
+ Raises:
+ ------
+ TypeError: If request is not an LLMRequest instance.
+ RateLimitError: If the API returns a 429 rate limit error.
+ TimeoutError: If the request exceeds the configured timeout.
+ RuntimeError: For other API errors or empty responses.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... max_tokens=512,
+ ... temperature=0.7,
+ ... )
+ >>> response = await llm.generate(request)
+ >>> print(response.content)
+ PMV (Predicted Mean Vote) is a thermal comfort model...
+ >>> print(f"Tokens used: {response.tokens_used}")
+ >>> print(f"Latency: {response.latency_ms}ms")
+
+ Note:
+ ----
+ For streaming responses, use the stream() method instead.
+
+ """
+ # Validate input type
+ if not isinstance(request, LLMRequest):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Ensure SDK is configured
+ genai = self._ensure_configured()
+
+ # Build system instruction with context for RAG grounding
+ system_instruction = self._build_system_instruction(request)
+
+ # Build chat contents with conversation history
+ contents = self._build_chat_contents(request)
+
+ # Create generation config
+ generation_config = genai.types.GenerationConfig(
+ temperature=request.temperature,
+ max_output_tokens=request.max_tokens,
+ )
+
+ # Create model with system instruction and generation config
+ # The system_instruction parameter sets the model's behavior and context
+ model = genai.GenerativeModel(
+ model_name=self._model_name,
+ generation_config=generation_config,
+ system_instruction=system_instruction,
+ )
+
+ # Record start time for latency calculation
+ start_time = time.perf_counter()
+
+ try:
+ # Calculate timeout in seconds
+ timeout_seconds = self._timeout_ms / 1000.0
+
+ # Make the API call with chat contents (supports multi-turn)
+ response = await asyncio.wait_for(
+ asyncio.to_thread(model.generate_content, contents),
+ timeout=timeout_seconds,
+ )
+
+ except Exception as e:
+ self._handle_error(e)
+ # _handle_error always raises, but this satisfies the type checker
+ raise # pragma: no cover
+
+ # Calculate latency
+ end_time = time.perf_counter()
+ latency_ms = int((end_time - start_time) * 1000)
+
+ # Validate response
+ if response is None:
+ msg = "Gemini API returned None response"
+ raise RuntimeError(msg)
+
+ # Extract response text
+ content = getattr(response, "text", None)
+ if not content or not content.strip():
+ msg = "Gemini API returned empty response"
+ raise RuntimeError(msg)
+
+ # Extract token count (default to 0 if not available)
+ tokens_used = 0
+ usage_metadata = getattr(response, "usage_metadata", None)
+ if usage_metadata is not None:
+ token_count = getattr(usage_metadata, "total_token_count", None)
+ if token_count is not None:
+ tokens_used = int(token_count)
+
+ # Build and return response
+ return LLMResponse(
+ content=content.strip(),
+ provider=self._provider_name,
+ model=self._model_name,
+ tokens_used=tokens_used,
+ latency_ms=latency_ms,
+ )
+
+ async def stream( # type: ignore[override]
+ self, request: LLMRequest
) -> AsyncIterator[str]:
- """Stream a response using Gemini.
+ """Stream a response for the given request.
+
+ Sends the request to the Gemini API and yields response chunks
+ as they are generated. This enables real-time display of responses
+ in chat interfaces with minimal latency to first token.
Args:
----
- prompt: User query or prompt.
- context: Retrieved context chunks to include.
- system_prompt: Optional system prompt override.
+ request: The LLMRequest containing query, context, and parameters.
+ Must be a valid LLMRequest instance.
Yields:
------
- String chunks of the generated response.
+ String chunks of the generated response as they become available.
Raises:
------
- NotImplementedError: Method will be implemented in Step 3.2.
+ TypeError: If request is not an LLMRequest instance.
+ RateLimitError: If the API returns a 429 rate limit error.
+ TimeoutError: If the request exceeds the configured timeout.
+ RuntimeError: For other API errors.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... )
+ >>> async for chunk in llm.stream(request):
+ ... print(chunk, end="", flush=True)
+ PMV (Predicted Mean Vote) is a thermal comfort model...
+
+ Note:
+ ----
+ Streaming does not provide token usage or latency metadata.
+ Use generate() if you need this information.
"""
- raise NotImplementedError("stream() will be implemented in Step 3.2")
- # This yield is needed to make this an async generator
- # It will be replaced with actual implementation
- yield "" # pragma: no cover
+ # Validate input type
+ if not isinstance(request, LLMRequest):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Ensure SDK is configured
+ genai = self._ensure_configured()
+
+ # Build system instruction with context for RAG grounding
+ system_instruction = self._build_system_instruction(request)
+
+ # Build chat contents with conversation history
+ contents = self._build_chat_contents(request)
+
+ # Create generation config
+ generation_config = genai.types.GenerationConfig(
+ temperature=request.temperature,
+ max_output_tokens=request.max_tokens,
+ )
+
+ # Create model with system instruction and generation config
+ model = genai.GenerativeModel(
+ model_name=self._model_name,
+ generation_config=generation_config,
+ system_instruction=system_instruction,
+ )
+
+ try:
+ # Calculate timeout in seconds
+ timeout_seconds = self._timeout_ms / 1000.0
+
+ # Make the streaming API call with chat contents (supports multi-turn)
+ # The generate_content call with stream=True returns an iterator
+ response_iterator = await asyncio.wait_for(
+ asyncio.to_thread(
+ model.generate_content,
+ contents,
+ stream=True,
+ ),
+ timeout=timeout_seconds,
+ )
+
+ except Exception as e:
+ self._handle_error(e)
+ # _handle_error always raises, but we need to yield for type checking
+ raise # pragma: no cover
+
+ # Iterate over response chunks and yield text
+ # We wrap iteration in to_thread since the iterator may block
+ try:
+ for chunk in response_iterator:
+ # Safely extract text from chunk
+ # Some chunks may be empty or have no valid parts (e.g., safety filters)
+ # The SDK's .text accessor can raise ValueError for invalid chunks
+ try:
+ # First check if chunk has valid parts before accessing .text
+ # This avoids the "response.text quick accessor" error
+ if hasattr(chunk, "parts") and chunk.parts:
+ text = getattr(chunk, "text", None)
+ if text:
+ yield text
+ elif hasattr(chunk, "text"):
+ # Fallback: try direct text access with error handling
+ with contextlib.suppress(ValueError, AttributeError):
+ text = chunk.text
+ if text:
+ yield text
+ except (ValueError, AttributeError):
+ # Skip chunks that don't have valid text
+ # This can happen for safety-filtered or empty final chunks
+ continue
+
+ except Exception as e:
+ # Only handle actual API errors, not empty chunk errors
+ error_str = str(e).lower()
+ # Ignore "quick accessor" errors - these are just empty final chunks
+ if "quick accessor" in error_str or "no valid part" in error_str:
+ return
+ self._handle_error(e)
+ raise # pragma: no cover
diff --git a/src/rag_chatbot/llm/groq.py b/src/rag_chatbot/llm/groq.py
index 35a1e1243dc74c83e23e88b2f65b4d9f6a4098e3..1dcca55cc638d6869f82dd6e0adef62a74e286d5 100644
--- a/src/rag_chatbot/llm/groq.py
+++ b/src/rag_chatbot/llm/groq.py
@@ -1,141 +1,791 @@
"""Groq LLM provider implementation.
This module provides the GroqLLM class for integrating with Groq's
-fast inference API. Groq serves as a secondary provider offering:
- - Extremely fast inference speeds
- - Competitive pricing
+fast inference API. Groq serves as an alternative provider to Google Gemini,
+offering:
+ - Extremely fast inference speeds via custom LPU hardware
+ - Competitive free tier rate limits
- Support for various open-source models
+Supported Models (Free Tier):
+ - openai/gpt-oss-120b: 30 RPM, 8K TPM, 1K RPD, 200K TPD (Primary)
+ - llama-3.3-70b-versatile: 30 RPM, 12K TPM, 1K RPD, 100K TPD (Secondary)
+
+Components:
+ - GroqLLM: Main provider class implementing BaseLLM interface
+ - GroqRateLimitError: Exception raised when API returns 429 status
+
Lazy Loading:
- groq SDK is loaded on first use to avoid import overhead.
+ The groq SDK is loaded on first API call, not at import time.
+ This follows the project's lazy loading pattern to minimize startup
+ overhead and memory usage when the provider is not actively used.
+
+Design Principles:
+ - Async-first: All API calls use asyncio for non-blocking operation
+ - Type-safe: Full type annotations compatible with mypy strict mode
+ - Error handling: Graceful handling of rate limits, timeouts, and errors
+ - Testable: Dependency injection friendly with mockable SDK loading
+
+Example:
+-------
+ >>> from rag_chatbot.llm.groq import GroqLLM, GroqRateLimitError
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Initialize with API key
+ >>> llm = GroqLLM(api_key="your-api-key")
+ >>>
+ >>> # Check availability
+ >>> if llm.is_available:
+ ... request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... )
+ ... response = await llm.generate(request)
+ ... print(response.content)
Note:
----
- This is a placeholder that will be fully implemented in Step 3.2.
+ Requires the groq package to be installed:
+ `pip install groq`
"""
from __future__ import annotations
+import asyncio
+import contextlib
+import time
from collections.abc import AsyncIterator
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
-if TYPE_CHECKING:
- from collections.abc import Sequence
+from .base import BaseLLM, LLMRequest, LLMResponse
+from .prompts import MAX_HISTORY_MESSAGES, THERMAL_COMFORT_SYSTEM_PROMPT
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# HTTP status code for rate limiting
+_HTTP_429_RATE_LIMIT: int = 429
+
+# Default Groq model (primary model with good balance)
+_DEFAULT_MODEL: str = "openai/gpt-oss-120b"
+
+# Default timeout in milliseconds
+_DEFAULT_TIMEOUT_MS: int = 30000
+
+# Provider identifier
+_PROVIDER_NAME: str = "groq"
- from .base import LLMResponse
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
+
+if TYPE_CHECKING:
+ import groq # noqa: F401
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["GroqLLM"]
+__all__: list[str] = ["GroqLLM", "GroqRateLimitError"]
+
+
+# =============================================================================
+# Lazy Loading Infrastructure
+# =============================================================================
+# The groq module is loaded on first use to avoid import overhead at module
+# load time. This is critical for fast server startup.
+# =============================================================================
+
+# Module-level cache for lazy-loaded groq module
+_groq_module: object | None = None
+
+
+def _get_groq() -> Any: # noqa: ANN401
+ """Lazy load groq SDK on first use.
+
+ This function implements the lazy loading pattern required by the
+ project architecture. The groq SDK is only imported when this function
+ is called, typically during the first API call.
+
+ Returns:
+ -------
+ The groq module.
+
+ Raises:
+ ------
+ ImportError: If groq package is not installed.
+
+ Example:
+ -------
+ >>> groq = _get_groq()
+ >>> client = groq.Groq(api_key="...")
+
+ Note:
+ ----
+ The module is cached globally after first import, so subsequent
+ calls return the cached reference without re-importing.
+
+ """
+ global _groq_module # noqa: PLW0603
+ if _groq_module is None:
+ import groq
+
+ _groq_module = groq
+ return _groq_module
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
-class GroqLLM: # pragma: no cover
+class GroqRateLimitError(Exception):
+ """Exception raised when the Groq API returns a 429 rate limit error.
+
+ This exception is raised when the API indicates that the request rate
+ has exceeded the allowed limit. It includes information about when the
+ client should retry the request.
+
+ Groq has multiple rate limit dimensions:
+ - RPM (Requests Per Minute): 30 for free tier
+ - TPM (Tokens Per Minute): 8K-12K depending on model
+ - RPD (Requests Per Day): 1K for free tier
+ - TPD (Tokens Per Day): 100K-200K depending on model
+
+ Attributes:
+ ----------
+ retry_after : int
+ The number of seconds to wait before retrying the request.
+ Defaults to 60 seconds if not provided by the API.
+
+ Example:
+ -------
+ >>> try:
+ ... response = await llm.generate(request)
+ ... except GroqRateLimitError as e:
+ ... print(f"Rate limited. Retry after {e.retry_after} seconds")
+ ... await asyncio.sleep(e.retry_after)
+
+ Note:
+ ----
+ The retry_after value should be used to implement exponential
+ backoff or cooldown periods in the calling code.
+
+ """
+
+ def __init__(self, message: str, retry_after: int | None = None) -> None:
+ """Initialize GroqRateLimitError with message and retry information.
+
+ Args:
+ ----
+ message: Human-readable error message describing the rate limit.
+ retry_after: Number of seconds to wait before retrying.
+ Defaults to 60 seconds if not provided.
+
+ Example:
+ -------
+ >>> raise GroqRateLimitError("Rate limit exceeded", retry_after=30)
+ >>> raise GroqRateLimitError("Too many requests") # Uses default 60s
+
+ """
+ super().__init__(message)
+ # Default to 60 seconds if no retry_after provided (shorter than Gemini
+ # because Groq has per-minute limits that reset quickly)
+ self.retry_after: int = retry_after if retry_after is not None else 60
+
+
+# =============================================================================
+# GroqLLM Provider Class
+# =============================================================================
+
+
+class GroqLLM(BaseLLM):
"""Groq LLM provider implementation.
- This class provides integration with Groq's API for fast text
- generation. It implements the BaseLLM interface with support for:
- - Async generation
- - Streaming responses
- - Automatic retries
+ This class provides integration with Groq's API for fast text generation.
+ It implements the BaseLLM interface with full support for:
+ - Async generation via asyncio
+ - Streaming responses via async iteration
+ - Rate limit handling with retry information
- Token usage tracking
+ - Latency measurement
- Groq provides extremely fast inference using their custom LPU
- hardware, making it ideal when response time is critical.
+ The default model is 'openai/gpt-oss-120b' which provides good performance
+ with 30 RPM and 8K TPM limits on the free tier.
+
+ Groq's LPU (Language Processing Unit) hardware provides extremely fast
+ inference speeds, making it ideal for interactive applications.
Attributes:
----------
- api_key: Groq API key for authentication.
- model_name: Groq-hosted model to use.
- timeout_ms: Request timeout in milliseconds.
+ provider_name : str
+ Always "groq" for this provider.
+ model_name : str
+ The Groq model to use (default: "openai/gpt-oss-120b").
+ timeout_ms : int
+ Request timeout in milliseconds (default: 30000).
Example:
-------
- >>> llm = GroqLLM(api_key="...")
- >>> response = await llm.generate("What is PMV?", context=[...])
- >>> print(response.content)
+ >>> from rag_chatbot.llm.groq import GroqLLM
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Initialize with API key
+ >>> llm = GroqLLM(api_key="your-api-key")
+ >>>
+ >>> # Check if provider is available
+ >>> if llm.is_available:
+ ... # Create a request
+ ... request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... max_tokens=512,
+ ... temperature=0.7,
+ ... )
+ ...
+ ... # Generate response
+ ... response = await llm.generate(request)
+ ... print(response.content)
+ ...
+ ... # Or stream the response
+ ... async for chunk in llm.stream(request):
+ ... print(chunk, end="", flush=True)
Note:
----
- This class will be fully implemented in Phase 3 (Step 3.2).
+ The Groq SDK is lazily loaded on first API call to minimize
+ startup overhead. The API key is validated only for non-empty
+ format; actual authentication happens on first request.
"""
def __init__(
self,
- api_key: str,
- model_name: str = "llama-3.1-70b-versatile",
- timeout_ms: int = 30000,
+ api_key: str | None,
+ model: str = _DEFAULT_MODEL,
+ timeout_ms: int = _DEFAULT_TIMEOUT_MS,
) -> None:
"""Initialize the Groq LLM provider.
Args:
----
- api_key: Groq API key for authentication.
- model_name: Groq-hosted model to use. Defaults to
- 'llama-3.1-70b-versatile'.
- timeout_ms: Request timeout in milliseconds. Defaults to 30000.
+ api_key: Groq API key for authentication. Must be a non-empty
+ string. Can be obtained from https://console.groq.com/
+ model: Groq model to use. Defaults to 'openai/gpt-oss-120b'.
+ Other options include 'llama-3.3-70b-versatile'.
+ timeout_ms: Request timeout in milliseconds. Defaults to 30000
+ (30 seconds). Increase for complex queries.
Raises:
------
- NotImplementedError: GroqLLM will be implemented in Step 3.2.
+ ValueError: If api_key is None, empty, or whitespace-only.
+
+ Example:
+ -------
+ >>> # Basic initialization
+ >>> llm = GroqLLM(api_key="your-api-key")
+ >>>
+ >>> # Custom model and timeout
+ >>> llm = GroqLLM(
+ ... api_key="your-api-key",
+ ... model="llama-3.3-70b-versatile",
+ ... timeout_ms=60000,
+ ... )
+
+ Note:
+ ----
+ The API key is stored but not validated until the first API call.
+ This allows for fast initialization even when the API is unavailable.
"""
- self._api_key = api_key
- self._model_name = model_name
- self._timeout_ms = timeout_ms
- raise NotImplementedError("GroqLLM will be implemented in Step 3.2")
+ # Validate api_key is not empty
+ if api_key is None or not api_key.strip():
+ msg = "api_key cannot be empty"
+ raise ValueError(msg)
- async def generate(
- self,
- prompt: str,
- context: Sequence[str],
- system_prompt: str | None = None,
- ) -> LLMResponse:
- """Generate a response using Groq.
+ # Initialize base class with provider configuration
+ super().__init__(
+ provider_name=_PROVIDER_NAME,
+ model_name=model,
+ timeout_ms=timeout_ms,
+ )
+
+ # Store API key (stripped of whitespace)
+ self._api_key: str = api_key.strip()
+
+ # Client instance - created lazily on first API call
+ self._client: object | None = None
+
+ # Track rate limit state for monitoring
+ self._last_rate_limit_time: float = 0.0
+
+ @property
+ def is_available(self) -> bool:
+ """Check if this provider is available for use.
+
+ Returns True if the API key is set and non-empty. This is a fast
+ check that does not make network calls.
+
+ Returns:
+ -------
+ True if API key is configured, False otherwise.
+
+ Example:
+ -------
+ >>> llm = GroqLLM(api_key="valid-key")
+ >>> llm.is_available
+ True
+ >>>
+ >>> # After clearing the key (for testing)
+ >>> llm._api_key = ""
+ >>> llm.is_available
+ False
+
+ Note:
+ ----
+ This property only checks if the API key is set. It does not
+ verify that the key is valid or that the API is reachable.
+ Use check_health() for thorough connectivity verification.
+
+ """
+ return bool(self._api_key)
+
+ def _ensure_client(self) -> Any: # noqa: ANN401
+ """Ensure the Groq client is initialized.
+
+ This method implements the lazy loading pattern. It creates the
+ Groq client instance on first call. Subsequent calls return the
+ cached client.
+
+ Returns:
+ -------
+ The initialized Groq client.
+
+ Example:
+ -------
+ >>> # Called internally before any API operation
+ >>> client = self._ensure_client()
+ >>> response = client.chat.completions.create(...)
+
+ Note:
+ ----
+ This method is idempotent - calling it multiple times is safe
+ and efficient. The client is only created once per instance.
+
+ """
+ if self._client is None:
+ groq = _get_groq()
+ self._client = groq.Groq(api_key=self._api_key)
+ return self._client
+
+ def _build_messages(self, request: LLMRequest) -> list[dict[str, str]]:
+ """Build the messages array from an LLMRequest with history support.
+
+ Constructs a chat completion messages array that includes:
+ 1. System message with comprehensive prompt and retrieved context
+ 2. Conversation history (if provided)
+ 3. Current user query
+
+ The format follows the OpenAI chat completion API standard,
+ which Groq's API is compatible with.
Args:
----
- prompt: User query or prompt.
- context: Retrieved context chunks to include.
- system_prompt: Optional system prompt override.
+ request: The LLMRequest containing query, context, and history.
Returns:
-------
- LLMResponse with generated content and metadata.
+ List of message dicts ready for the chat completions API.
+ Each dict has "role" (system/user/assistant) and "content".
+
+ Example:
+ -------
+ >>> from rag_chatbot.llm.base import ChatMessage
+ >>> history = [
+ ... ChatMessage(role="user", content="Hello"),
+ ... ChatMessage(role="assistant", content="Hi there!"),
+ ... ]
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for...", "The model was developed..."],
+ ... history=history,
+ ... )
+ >>> messages = self._build_messages(request)
+ >>> len(messages)
+ 4 # system + 2 history + user
+
+ Note:
+ ----
+ - Uses the comprehensive THERMAL_COMFORT_SYSTEM_PROMPT
+ - History is limited to MAX_HISTORY_MESSAGES to prevent overflow
+ - Groq uses standard OpenAI roles: system, user, assistant
+
+ """
+ messages: list[dict[str, str]] = []
+
+ # =================================================================
+ # 1. Build system message with comprehensive prompt and context
+ # =================================================================
+ # Start with the domain-specific system prompt that defines
+ # the assistant's behavior, expertise, and response guidelines
+ system_content = THERMAL_COMFORT_SYSTEM_PROMPT
+
+ # Append retrieved context chunks if provided
+ # This grounds the model's responses in the knowledge base
+ if request.context:
+ context_text = "\n\n".join(request.context)
+ system_content += f"\n\n**Retrieved Context:**\n{context_text}"
+
+ messages.append({"role": "system", "content": system_content})
+
+ # =================================================================
+ # 2. Add conversation history (limited to prevent context overflow)
+ # =================================================================
+ # Take the most recent messages if history exceeds the limit
+ # This ensures multi-turn context while managing token budget
+ history_to_use = request.history[-MAX_HISTORY_MESSAGES:]
+
+ for msg in history_to_use:
+ # Groq uses standard OpenAI roles, so "user" and "assistant"
+ # map directly without conversion
+ messages.append({
+ "role": msg.role,
+ "content": msg.content,
+ })
+
+ # =================================================================
+ # 3. Add current user query as the final message
+ # =================================================================
+ messages.append({"role": "user", "content": request.query})
+
+ return messages
+
+ def _handle_error(self, error: Exception) -> None:
+ """Handle API errors and convert to appropriate exceptions.
+
+ This method examines the error and raises the appropriate
+ exception type (GroqRateLimitError, TimeoutError, or RuntimeError).
+
+ Args:
+ ----
+ error: The exception caught from the API call.
Raises:
------
- NotImplementedError: Method will be implemented in Step 3.2.
+ GroqRateLimitError: If the error indicates a 429 rate limit.
+ TimeoutError: If the error indicates a timeout.
+ RuntimeError: For all other API errors.
+
+ Example:
+ -------
+ >>> try:
+ ... response = client.chat.completions.create(...)
+ ... except Exception as e:
+ ... self._handle_error(e) # Will re-raise appropriately
+
+ Note:
+ ----
+ This method always raises an exception - it never returns normally.
"""
- raise NotImplementedError("generate() will be implemented in Step 3.2")
+ # Check for timeout errors
+ if isinstance(error, asyncio.TimeoutError):
+ raise TimeoutError(str(error)) from error
- async def stream(
- self,
- _prompt: str,
- _context: Sequence[str],
- _system_prompt: str | None = None,
+ # Check for rate limit (429) errors
+ # The Groq SDK raises various exception types, so we check attributes
+ error_str = str(error).lower()
+ status_code = getattr(error, "status_code", None)
+
+ is_rate_limit = (
+ status_code == _HTTP_429_RATE_LIMIT
+ or "429" in error_str
+ or "rate limit" in error_str
+ or "rate_limit" in error_str
+ or "too many requests" in error_str
+ )
+
+ if is_rate_limit:
+ # Record rate limit time for monitoring
+ self._last_rate_limit_time = time.time()
+
+ # Try to extract retry-after information
+ retry_after: int | None = None
+
+ # Check for retry_after attribute on error
+ retry_value = getattr(error, "retry_after", None)
+ if retry_value is not None:
+ with contextlib.suppress(ValueError, TypeError):
+ retry_after = int(retry_value)
+
+ # Check headers if available
+ if retry_after is None:
+ headers = getattr(error, "headers", None)
+ if headers and isinstance(headers, dict):
+ header_value = headers.get("retry-after") or headers.get(
+ "Retry-After"
+ )
+ if header_value is not None:
+ with contextlib.suppress(ValueError, TypeError):
+ retry_after = int(header_value)
+
+ raise GroqRateLimitError(str(error), retry_after=retry_after) from error
+
+ # All other errors become RuntimeError
+ raise RuntimeError(str(error)) from error
+
+ async def check_health(self) -> bool:
+ """Check if the provider is healthy and responsive.
+
+ Makes a lightweight API call to verify that the Groq API is
+ reachable and responding. This is useful for health checks and
+ load balancing decisions.
+
+ Returns:
+ -------
+ True if the API is healthy and responsive, False otherwise.
+
+ Example:
+ -------
+ >>> llm = GroqLLM(api_key="valid-key")
+ >>> if await llm.check_health():
+ ... print("Groq API is healthy")
+ ... else:
+ ... print("Groq API health check failed")
+
+ Note:
+ ----
+ This method catches all exceptions and returns False for any
+ failure. It does not raise exceptions to ensure it can be used
+ safely in health check endpoints.
+
+ """
+ try:
+ client = self._ensure_client()
+
+ # Make a minimal request to verify connectivity
+ # Using a simple short prompt to minimize token usage
+ response = await asyncio.to_thread(
+ client.chat.completions.create,
+ model=self._model_name,
+ messages=[{"role": "user", "content": "Say OK"}],
+ max_tokens=5,
+ )
+
+ # Check that we got a valid response
+ return (
+ response is not None
+ and hasattr(response, "choices")
+ and len(response.choices) > 0
+ )
+
+ except Exception:
+ # Any error means the health check failed
+ return False
+
+ async def generate(self, request: LLMRequest) -> LLMResponse:
+ """Generate a complete response for the given request.
+
+ Sends the request to the Groq API and waits for the complete
+ response. This method handles message construction, API calls,
+ error handling, and response parsing.
+
+ Args:
+ ----
+ request: The LLMRequest containing query, context, and parameters.
+ Must be a valid LLMRequest instance.
+
+ Returns:
+ -------
+ LLMResponse with generated content and metadata including
+ provider name, model, token count, and latency.
+
+ Raises:
+ ------
+ TypeError: If request is not an LLMRequest instance.
+ GroqRateLimitError: If the API returns a 429 rate limit error.
+ TimeoutError: If the request exceeds the configured timeout.
+ RuntimeError: For other API errors or empty responses.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... max_tokens=512,
+ ... temperature=0.7,
+ ... )
+ >>> response = await llm.generate(request)
+ >>> print(response.content)
+ PMV (Predicted Mean Vote) is a thermal comfort model...
+ >>> print(f"Tokens used: {response.tokens_used}")
+ >>> print(f"Latency: {response.latency_ms}ms")
+
+ Note:
+ ----
+ For streaming responses, use the stream() method instead.
+
+ """
+ # Validate input type
+ if not isinstance(request, LLMRequest):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Ensure client is initialized
+ client = self._ensure_client()
+
+ # Build messages from request
+ messages = self._build_messages(request)
+
+ # Record start time for latency calculation
+ start_time = time.perf_counter()
+
+ try:
+ # Calculate timeout in seconds
+ timeout_seconds = self._timeout_ms / 1000.0
+
+ # Make the API call with timeout
+ response = await asyncio.wait_for(
+ asyncio.to_thread(
+ client.chat.completions.create,
+ model=self._model_name,
+ messages=messages,
+ max_tokens=request.max_tokens,
+ temperature=request.temperature,
+ ),
+ timeout=timeout_seconds,
+ )
+
+ except Exception as e:
+ self._handle_error(e)
+ # _handle_error always raises, but this satisfies the type checker
+ raise # pragma: no cover
+
+ # Calculate latency
+ end_time = time.perf_counter()
+ latency_ms = int((end_time - start_time) * 1000)
+
+ # Validate response
+ if response is None:
+ msg = "Groq API returned None response"
+ raise RuntimeError(msg)
+
+ # Extract response content
+ if not response.choices or len(response.choices) == 0:
+ msg = "Groq API returned empty choices"
+ raise RuntimeError(msg)
+
+ content = response.choices[0].message.content
+ if not content or not content.strip():
+ msg = "Groq API returned empty response"
+ raise RuntimeError(msg)
+
+ # Extract token count (default to 0 if not available)
+ tokens_used = 0
+ if response.usage is not None:
+ total_tokens = getattr(response.usage, "total_tokens", None)
+ if total_tokens is not None:
+ tokens_used = int(total_tokens)
+
+ # Build and return response
+ return LLMResponse(
+ content=content.strip(),
+ provider=self._provider_name,
+ model=self._model_name,
+ tokens_used=tokens_used,
+ latency_ms=latency_ms,
+ )
+
+ async def stream( # type: ignore[override]
+ self, request: LLMRequest
) -> AsyncIterator[str]:
- """Stream a response using Groq.
+ """Stream a response for the given request.
+
+ Sends the request to the Groq API and yields response chunks
+ as they are generated. This enables real-time display of responses
+ in chat interfaces with minimal latency to first token.
Args:
----
- prompt: User query or prompt.
- context: Retrieved context chunks to include.
- system_prompt: Optional system prompt override.
+ request: The LLMRequest containing query, context, and parameters.
+ Must be a valid LLMRequest instance.
Yields:
------
- String chunks of the generated response.
+ String chunks of the generated response as they become available.
Raises:
------
- NotImplementedError: Method will be implemented in Step 3.2.
+ TypeError: If request is not an LLMRequest instance.
+ GroqRateLimitError: If the API returns a 429 rate limit error.
+ TimeoutError: If the request exceeds the configured timeout.
+ RuntimeError: For other API errors.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... )
+ >>> async for chunk in llm.stream(request):
+ ... print(chunk, end="", flush=True)
+ PMV (Predicted Mean Vote) is a thermal comfort model...
+
+ Note:
+ ----
+ Streaming does not provide token usage or latency metadata.
+ Use generate() if you need this information.
"""
- raise NotImplementedError("stream() will be implemented in Step 3.2")
- # This yield is needed to make this an async generator
- # It will be replaced with actual implementation
- yield "" # pragma: no cover
+ # Validate input type
+ if not isinstance(request, LLMRequest):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Ensure client is initialized
+ client = self._ensure_client()
+
+ # Build messages from request
+ messages = self._build_messages(request)
+
+ try:
+ # Calculate timeout in seconds
+ timeout_seconds = self._timeout_ms / 1000.0
+
+ # Make the streaming API call with timeout
+ response_stream = await asyncio.wait_for(
+ asyncio.to_thread(
+ client.chat.completions.create,
+ model=self._model_name,
+ messages=messages,
+ max_tokens=request.max_tokens,
+ temperature=request.temperature,
+ stream=True,
+ ),
+ timeout=timeout_seconds,
+ )
+
+ except Exception as e:
+ self._handle_error(e)
+ # _handle_error always raises, but we need to yield for type checking
+ raise # pragma: no cover
+
+ # Iterate over response chunks and yield content
+ try:
+ for chunk in response_stream:
+ # Extract content from chunk delta
+ if chunk.choices and len(chunk.choices) > 0:
+ delta = chunk.choices[0].delta
+ if delta is not None:
+ content = getattr(delta, "content", None)
+ if content:
+ yield content
+
+ except Exception as e:
+ # Handle errors during streaming
+ self._handle_error(e)
+ raise # pragma: no cover
diff --git a/src/rag_chatbot/llm/groq_quota.py b/src/rag_chatbot/llm/groq_quota.py
new file mode 100644
index 0000000000000000000000000000000000000000..511052b8bd72789e7b057df191a76031328ac042
--- /dev/null
+++ b/src/rag_chatbot/llm/groq_quota.py
@@ -0,0 +1,768 @@
+"""Quota management for Groq LLM provider.
+
+This module provides the GroqQuotaManager class for tracking API usage
+across Groq LLM models and determining model availability based on
+free tier rate limits.
+
+Groq has four rate limit dimensions:
+ - RPM (Requests Per Minute): 30 for all models
+ - TPM (Tokens Per Minute): varies by model (8K-12K)
+ - RPD (Requests Per Day): 1,000 for all models
+ - TPD (Tokens Per Day): varies by model (100K-200K)
+
+Supported Models and Rate Limits (Free Tier):
+ - openai/gpt-oss-120b: 30 RPM, 8,000 TPM, 1,000 RPD, 200,000 TPD
+ - llama-3.3-70b-versatile: 30 RPM, 12,000 TPM, 1,000 RPD, 100,000 TPD
+
+Components:
+ - GroqModelQuota: Pydantic model for rate limit configuration (immutable)
+ - GroqModelUsage: Pydantic model for usage tracking (mutable)
+ - GroqModelStatus: Pydantic model for availability status (immutable)
+ - GroqQuotaManager: Main class for quota tracking and availability checks
+
+Thread Safety:
+ All counter updates are protected by threading.Lock to ensure
+ thread-safe operation in concurrent environments.
+
+Lazy Loading:
+ No heavy dependencies - this module loads quickly without
+ importing torch, transformers, or other heavy packages.
+
+Example:
+-------
+ >>> from rag_chatbot.llm.groq_quota import GroqQuotaManager
+ >>>
+ >>> # Create a quota manager
+ >>> manager = GroqQuotaManager()
+ >>>
+ >>> # Check if a model is available
+ >>> if manager.is_available("openai/gpt-oss-120b"):
+ ... # Record usage after successful request
+ ... manager.record_usage("openai/gpt-oss-120b", tokens=500)
+ ...
+ >>> # Handle rate limit (429) errors
+ >>> manager.record_rate_limit("openai/gpt-oss-120b", retry_after=60)
+ >>>
+ >>> # Get detailed status for a model
+ >>> status = manager.get_status("openai/gpt-oss-120b")
+ >>> print(f"Available: {status.is_available}")
+ >>> print(f"Requests remaining: {status.requests_remaining_minute}")
+
+"""
+
+from __future__ import annotations
+
+import threading
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+from pydantic import BaseModel, ConfigDict, Field
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+
+if TYPE_CHECKING:
+ pass # No type-only imports needed currently
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "GroqModelQuota",
+ "GroqModelUsage",
+ "GroqModelStatus",
+ "GroqQuotaManager",
+]
+
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Default cooldown period in seconds when no Retry-After header is provided
+# 60 seconds is reasonable for Groq since their limits reset per-minute
+_DEFAULT_COOLDOWN_SECONDS: int = 60
+
+# Model names for Groq LLMs (Free Tier)
+_MODEL_GPT_OSS_120B: str = "openai/gpt-oss-120b"
+_MODEL_LLAMA_70B: str = "llama-3.3-70b-versatile"
+
+# Model priority order (primary first)
+GROQ_MODEL_PRIORITY: list[str] = [
+ _MODEL_GPT_OSS_120B, # Primary: 30 RPM, 8K TPM, 1K RPD, 200K TPD
+ _MODEL_LLAMA_70B, # Secondary: 30 RPM, 12K TPM, 1K RPD, 100K TPD
+]
+
+
+# =============================================================================
+# Pydantic Models
+# =============================================================================
+
+
+class GroqModelQuota(BaseModel):
+ """Rate limit configuration for a Groq LLM model.
+
+ This immutable model stores the rate limits for a specific model.
+ All values represent the maximum allowed usage per time window.
+
+ Groq has four rate limit dimensions unlike Google (which has three):
+ - RPM: Requests Per Minute
+ - TPM: Tokens Per Minute
+ - RPD: Requests Per Day
+ - TPD: Tokens Per Day (Groq-specific)
+
+ Attributes:
+ ----------
+ rpm : int
+ Requests Per Minute limit. Maximum number of API requests
+ allowed within a 60-second sliding window.
+
+ tpm : int
+ Tokens Per Minute limit. Maximum number of tokens (input + output)
+ allowed within a 60-second sliding window.
+
+ rpd : int
+ Requests Per Day limit. Maximum number of API requests
+ allowed within a 24-hour period.
+
+ tpd : int
+ Tokens Per Day limit. Maximum number of tokens
+ allowed within a 24-hour period. This is Groq-specific.
+
+ Example:
+ -------
+ >>> quota = GroqModelQuota(rpm=30, tpm=8_000, rpd=1_000, tpd=200_000)
+ >>> quota.rpm
+ 30
+
+ Note:
+ ----
+ This model is frozen (immutable) since quota limits should not
+ change after initialization. Changes require creating a new instance.
+
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ extra="forbid",
+ validate_default=True,
+ )
+
+ rpm: int = Field(
+ ...,
+ gt=0,
+ description="Requests Per Minute limit",
+ )
+
+ tpm: int = Field(
+ ...,
+ gt=0,
+ description="Tokens Per Minute limit",
+ )
+
+ rpd: int = Field(
+ ...,
+ gt=0,
+ description="Requests Per Day limit",
+ )
+
+ tpd: int = Field(
+ ...,
+ gt=0,
+ description="Tokens Per Day limit (Groq-specific)",
+ )
+
+
+class GroqModelUsage(BaseModel):
+ """Usage tracking for a Groq LLM model.
+
+ This mutable model tracks current usage within rate limit windows.
+ Counters are reset periodically (every minute for RPM/TPM, daily for RPD/TPD).
+
+ Attributes:
+ ----------
+ requests_this_minute : int
+ Number of requests made in the current minute window.
+
+ tokens_this_minute : int
+ Number of tokens used in the current minute window.
+
+ requests_today : int
+ Number of requests made today (since midnight UTC).
+
+ tokens_today : int
+ Number of tokens used today (since midnight UTC).
+ This is tracked for Groq's TPD limit.
+
+ minute_started_at : datetime | None
+ Timestamp of first request in current minute window.
+
+ day_started_at : datetime | None
+ Timestamp of first request today.
+
+ cooldown_until : datetime | None
+ Timestamp when cooldown period ends (after 429 error).
+
+ Example:
+ -------
+ >>> usage = GroqModelUsage()
+ >>> usage.requests_this_minute = 5
+ >>> usage.requests_this_minute
+ 5
+
+ """
+
+ model_config = ConfigDict(
+ frozen=False,
+ extra="forbid",
+ validate_default=True,
+ )
+
+ requests_this_minute: int = Field(
+ default=0,
+ ge=0,
+ description="Number of requests made in current minute window",
+ )
+
+ tokens_this_minute: int = Field(
+ default=0,
+ ge=0,
+ description="Number of tokens used in current minute window",
+ )
+
+ requests_today: int = Field(
+ default=0,
+ ge=0,
+ description="Number of requests made today (since midnight UTC)",
+ )
+
+ tokens_today: int = Field(
+ default=0,
+ ge=0,
+ description="Number of tokens used today (since midnight UTC)",
+ )
+
+ minute_started_at: datetime | None = Field(
+ default=None,
+ description="Timestamp of first request in current minute window",
+ )
+
+ day_started_at: datetime | None = Field(
+ default=None,
+ description="Timestamp of first request today",
+ )
+
+ cooldown_until: datetime | None = Field(
+ default=None,
+ description="Timestamp when cooldown period ends (after 429 error)",
+ )
+
+
+class GroqModelStatus(BaseModel):
+ """Availability status for a Groq LLM model.
+
+ This immutable model provides a snapshot of model availability
+ and remaining quota. Used for monitoring and decision making.
+
+ Attributes:
+ ----------
+ model : str
+ Model name identifier (e.g., "openai/gpt-oss-120b").
+
+ is_available : bool
+ Whether the model is currently available for requests.
+
+ requests_remaining_minute : int
+ Number of requests remaining in current minute window.
+
+ tokens_remaining_minute : int
+ Number of tokens remaining in current minute window.
+
+ requests_remaining_day : int
+ Number of requests remaining today.
+
+ tokens_remaining_day : int
+ Number of tokens remaining today (Groq-specific TPD).
+
+ cooldown_seconds : int | None
+ Seconds remaining in cooldown period (after 429 error).
+
+ reason : str | None
+ Human-readable reason why model is unavailable.
+
+ Example:
+ -------
+ >>> status = GroqModelStatus(
+ ... model="openai/gpt-oss-120b",
+ ... is_available=True,
+ ... requests_remaining_minute=30,
+ ... tokens_remaining_minute=8_000,
+ ... requests_remaining_day=1_000,
+ ... tokens_remaining_day=200_000,
+ ... cooldown_seconds=None,
+ ... reason=None,
+ ... )
+ >>> status.is_available
+ True
+
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ extra="forbid",
+ validate_default=True,
+ )
+
+ model: str = Field(
+ ...,
+ min_length=1,
+ description="Model name identifier",
+ )
+
+ is_available: bool = Field(
+ ...,
+ description="Whether the model is currently available",
+ )
+
+ requests_remaining_minute: int = Field(
+ ...,
+ description="Requests remaining in current minute window",
+ )
+
+ tokens_remaining_minute: int = Field(
+ ...,
+ description="Tokens remaining in current minute window",
+ )
+
+ requests_remaining_day: int = Field(
+ ...,
+ description="Requests remaining today",
+ )
+
+ tokens_remaining_day: int = Field(
+ ...,
+ description="Tokens remaining today (Groq-specific TPD)",
+ )
+
+ cooldown_seconds: int | None = Field(
+ default=None,
+ description="Seconds remaining in cooldown period",
+ )
+
+ reason: str | None = Field(
+ default=None,
+ description="Human-readable reason why model is unavailable",
+ )
+
+
+# =============================================================================
+# GroqQuotaManager Class
+# =============================================================================
+
+
+class GroqQuotaManager:
+ """Manage API quotas for Groq LLM provider.
+
+ This class tracks API usage across Groq models and determines
+ availability based on free tier rate limits. It provides:
+ - Per-model quota tracking (RPM, TPM, RPD, TPD)
+ - Cooldown handling for 429 rate limit errors
+ - Thread-safe counter updates
+ - Status reporting for monitoring
+
+ Supported Models (Free Tier):
+ - openai/gpt-oss-120b: 30 RPM, 8,000 TPM, 1,000 RPD, 200,000 TPD
+ - llama-3.3-70b-versatile: 30 RPM, 12,000 TPM, 1,000 RPD, 100,000 TPD
+
+ Thread Safety:
+ All public methods are thread-safe. Counter updates are protected
+ by an internal threading.Lock to prevent race conditions.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>>
+ >>> # Check availability before making request
+ >>> if manager.is_available("openai/gpt-oss-120b"):
+ ... # Make API request...
+ ... response = await llm.generate(request)
+ ... # Record usage after success
+ ... manager.record_usage("openai/gpt-oss-120b", tokens=response.tokens_used)
+ ... else:
+ ... # Try fallback model
+ ... status = manager.get_status("openai/gpt-oss-120b")
+ ... print(f"Model unavailable: {status.reason}")
+
+ Note:
+ ----
+ The manager does not automatically reset counters. In production,
+ you should call reset_minute_counters() every 60 seconds and
+ reset_day_counters() at midnight UTC using a scheduler.
+
+ """
+
+ def __init__(self) -> None:
+ """Initialize the GroqQuotaManager with default quotas for all models.
+
+ Creates quota configurations and usage trackers for all supported
+ Groq models. All models start with zero usage and full quota.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> manager.is_available("openai/gpt-oss-120b")
+ True
+
+ """
+ # Thread lock for protecting counter updates
+ self._lock: threading.Lock = threading.Lock()
+
+ # Initialize quota configurations for all supported models
+ self._quotas: dict[str, GroqModelQuota] = {
+ _MODEL_GPT_OSS_120B: GroqModelQuota(
+ rpm=30,
+ tpm=8_000,
+ rpd=1_000,
+ tpd=200_000,
+ ),
+ _MODEL_LLAMA_70B: GroqModelQuota(
+ rpm=30,
+ tpm=12_000,
+ rpd=1_000,
+ tpd=100_000,
+ ),
+ }
+
+ # Initialize usage trackers for all supported models
+ self._usage: dict[str, GroqModelUsage] = {
+ model: GroqModelUsage(
+ requests_this_minute=0,
+ tokens_this_minute=0,
+ requests_today=0,
+ tokens_today=0,
+ minute_started_at=None,
+ day_started_at=None,
+ cooldown_until=None,
+ )
+ for model in self._quotas
+ }
+
+ def _validate_model(self, model: str) -> None:
+ """Validate that the model is supported.
+
+ Args:
+ ----
+ model: Model name to validate.
+
+ Raises:
+ ------
+ KeyError: If the model is not supported.
+
+ """
+ if model not in self._quotas:
+ supported = list(self._quotas.keys())
+ msg = f"Unknown model: {model!r}. Supported models: {supported}"
+ raise KeyError(msg)
+
+ def _get_now(self) -> datetime:
+ """Get the current UTC datetime.
+
+ This method is separated to allow for easier mocking in tests.
+
+ Returns
+ -------
+ Current datetime in UTC timezone.
+
+ """
+ return datetime.now(UTC)
+
+ def get_models(self) -> list[str]:
+ """Get list of supported models.
+
+ Returns
+ -------
+ List of supported model names.
+
+ """
+ return list(self._quotas.keys())
+
+ def is_available(self, model: str) -> bool:
+ """Check if a model is available for requests.
+
+ Checks all quota dimensions (RPM, TPM, RPD, TPD) and cooldown status
+ to determine if the model can accept new requests. A model is
+ available only if ALL of the following conditions are met:
+ - requests_this_minute < quota.rpm
+ - tokens_this_minute < quota.tpm
+ - requests_today < quota.rpd
+ - tokens_today < quota.tpd
+ - cooldown_until is None or now > cooldown_until
+
+ Args:
+ ----
+ model: Model name to check (e.g., "openai/gpt-oss-120b").
+
+ Returns:
+ -------
+ True if the model is available, False otherwise.
+
+ Raises:
+ ------
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> manager.is_available("openai/gpt-oss-120b")
+ True
+
+ """
+ self._validate_model(model)
+
+ with self._lock:
+ quota = self._quotas[model]
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Check cooldown status first
+ if usage.cooldown_until is not None and now < usage.cooldown_until:
+ return False
+
+ # Check RPM (Requests Per Minute)
+ if usage.requests_this_minute >= quota.rpm:
+ return False
+
+ # Check TPM (Tokens Per Minute)
+ if usage.tokens_this_minute >= quota.tpm:
+ return False
+
+ # Check RPD (Requests Per Day)
+ if usage.requests_today >= quota.rpd:
+ return False
+
+ # Check TPD (Tokens Per Day) - Groq-specific
+ if usage.tokens_today >= quota.tpd:
+ return False
+
+ return True
+
+ def record_usage(self, model: str, tokens: int) -> None:
+ """Record a successful API request for a model.
+
+ Updates all usage counters (RPM, TPM, RPD, TPD) for the specified model.
+
+ Args:
+ ----
+ model: Model name (e.g., "openai/gpt-oss-120b").
+ tokens: Number of tokens consumed by the request.
+
+ Raises:
+ ------
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> manager.record_usage("openai/gpt-oss-120b", tokens=500)
+
+ """
+ self._validate_model(model)
+
+ with self._lock:
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Set minute window start time if this is first request in window
+ if usage.minute_started_at is None:
+ usage.minute_started_at = now
+
+ # Set day window start time if this is first request today
+ if usage.day_started_at is None:
+ usage.day_started_at = now
+
+ # Increment all counters
+ usage.requests_this_minute += 1
+ usage.tokens_this_minute += tokens
+ usage.requests_today += 1
+ usage.tokens_today += tokens # Track TPD
+
+ def record_rate_limit(self, model: str, retry_after: int | None) -> None:
+ """Record a rate limit (429) error for a model.
+
+ Sets a cooldown period during which the model will be marked
+ as unavailable.
+
+ Args:
+ ----
+ model: Model name (e.g., "openai/gpt-oss-120b").
+ retry_after: Seconds to wait before retrying. If None, uses
+ default of 60 seconds.
+
+ Raises:
+ ------
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> manager.record_rate_limit("openai/gpt-oss-120b", retry_after=60)
+ >>> manager.is_available("openai/gpt-oss-120b")
+ False
+
+ """
+ self._validate_model(model)
+
+ cooldown_seconds = (
+ retry_after if retry_after is not None else _DEFAULT_COOLDOWN_SECONDS
+ )
+
+ with self._lock:
+ usage = self._usage[model]
+ now = self._get_now()
+ usage.cooldown_until = now + timedelta(seconds=cooldown_seconds)
+
+ def get_status(self, model: str) -> GroqModelStatus:
+ """Get detailed availability status for a model.
+
+ Returns a snapshot of the model's current quota state including
+ remaining requests/tokens and cooldown status.
+
+ Args:
+ ----
+ model: Model name (e.g., "openai/gpt-oss-120b").
+
+ Returns:
+ -------
+ GroqModelStatus with current availability information.
+
+ Raises:
+ ------
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> status = manager.get_status("openai/gpt-oss-120b")
+ >>> status.is_available
+ True
+
+ """
+ self._validate_model(model)
+
+ with self._lock:
+ quota = self._quotas[model]
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Calculate remaining quota
+ requests_remaining_minute = quota.rpm - usage.requests_this_minute
+ tokens_remaining_minute = quota.tpm - usage.tokens_this_minute
+ requests_remaining_day = quota.rpd - usage.requests_today
+ tokens_remaining_day = quota.tpd - usage.tokens_today
+
+ # Calculate cooldown seconds if in cooldown
+ cooldown_seconds: int | None = None
+ if usage.cooldown_until is not None and now < usage.cooldown_until:
+ delta = usage.cooldown_until - now
+ cooldown_seconds = int(delta.total_seconds())
+
+ # Determine availability and reason
+ is_available = True
+ reason: str | None = None
+
+ # Check cooldown first (highest priority)
+ if cooldown_seconds is not None and cooldown_seconds > 0:
+ is_available = False
+ reason = f"Model in cooldown for {cooldown_seconds}s (rate limit 429)"
+
+ elif requests_remaining_minute <= 0:
+ is_available = False
+ used = usage.requests_this_minute
+ limit = quota.rpm
+ reason = f"RPM quota exhausted ({used}/{limit} requests this minute)"
+
+ elif tokens_remaining_minute <= 0:
+ is_available = False
+ used = usage.tokens_this_minute
+ reason = f"TPM quota exhausted ({used}/{quota.tpm} tokens this minute)"
+
+ elif requests_remaining_day <= 0:
+ is_available = False
+ used = usage.requests_today
+ reason = f"RPD quota exhausted ({used}/{quota.rpd} requests today)"
+
+ elif tokens_remaining_day <= 0:
+ is_available = False
+ used = usage.tokens_today
+ reason = f"TPD quota exhausted ({used}/{quota.tpd} tokens today)"
+
+ return GroqModelStatus(
+ model=model,
+ is_available=is_available,
+ requests_remaining_minute=requests_remaining_minute,
+ tokens_remaining_minute=tokens_remaining_minute,
+ requests_remaining_day=requests_remaining_day,
+ tokens_remaining_day=tokens_remaining_day,
+ cooldown_seconds=cooldown_seconds,
+ reason=reason,
+ )
+
+ def get_all_status(self) -> list[GroqModelStatus]:
+ """Get availability status for all supported models.
+
+ Returns:
+ -------
+ List of GroqModelStatus objects for all supported models.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> statuses = manager.get_all_status()
+ >>> for status in statuses:
+ ... print(f"{status.model}: {status.is_available}")
+
+ """
+ return [self.get_status(model) for model in self._quotas]
+
+ def reset_minute_counters(self) -> None:
+ """Reset minute-based counters (RPM, TPM) for all models.
+
+ Resets requests_this_minute, tokens_this_minute, and
+ minute_started_at for all models. Call every 60 seconds.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> manager.record_usage("openai/gpt-oss-120b", tokens=100)
+ >>> manager.reset_minute_counters()
+
+ """
+ with self._lock:
+ for usage in self._usage.values():
+ usage.requests_this_minute = 0
+ usage.tokens_this_minute = 0
+ usage.minute_started_at = None
+
+ def reset_day_counters(self) -> None:
+ """Reset all counters (RPM, TPM, RPD, TPD) for all models.
+
+ Resets all counters including daily requests and tokens.
+ Call at midnight UTC.
+
+ Example:
+ -------
+ >>> manager = GroqQuotaManager()
+ >>> manager.record_usage("openai/gpt-oss-120b", tokens=100)
+ >>> manager.reset_day_counters()
+
+ """
+ with self._lock:
+ for usage in self._usage.values():
+ usage.requests_this_minute = 0
+ usage.tokens_this_minute = 0
+ usage.requests_today = 0
+ usage.tokens_today = 0
+ usage.minute_started_at = None
+ usage.day_started_at = None
+ # Note: cooldown_until is NOT reset
diff --git a/src/rag_chatbot/llm/groq_registry.py b/src/rag_chatbot/llm/groq_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2ece5100039652a2d1c6d04e139340f30209d15
--- /dev/null
+++ b/src/rag_chatbot/llm/groq_registry.py
@@ -0,0 +1,608 @@
+"""Provider Registry for managing Groq LLM model instances with automatic fallback.
+
+This module provides the GroqProviderRegistry class which manages multiple Groq
+LLM model instances and implements automatic fallback when rate limits are hit.
+The registry follows a priority-based fallback strategy to maximize availability.
+
+Components:
+ - GroqProviderRegistry: Main class for managing Groq model instances and fallback
+ - GroqAllModelsExhaustedError: Exception when all Groq models are rate limited
+ - GROQ_MODEL_PRIORITY: List of Groq models in fallback priority order
+ - GROQ_PROVIDER_TIMEOUT_MS: Default timeout for Groq provider requests
+
+Model Priority Order (Free Tier):
+ 1. openai/gpt-oss-120b: 30 RPM, 8K TPM, 1K RPD, 200K TPD (Primary)
+ 2. llama-3.3-70b-versatile: 30 RPM, 12K TPM, 1K RPD, 100K TPD (Secondary)
+
+Thread Safety:
+ Model creation is protected by a threading.Lock to ensure only one
+ instance is created per model, even under concurrent access.
+
+Lazy Loading:
+ Models are created on first use, not at initialization. This minimizes
+ startup overhead and memory usage.
+
+Example:
+-------
+ >>> from rag_chatbot.llm.groq_registry import GroqProviderRegistry
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Initialize with API key
+ >>> registry = GroqProviderRegistry(api_key="your-api-key")
+ >>>
+ >>> # Check availability
+ >>> if registry.is_available:
+ ... request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... )
+ ... # Auto-fallback on rate limits
+ ... response = await registry.generate(request)
+ ... print(response.content)
+
+Note:
+----
+ Requires the groq package to be installed for GroqLLM.
+ The GroqQuotaManager is used to track usage and determine model availability.
+
+"""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING
+
+from .groq import GroqLLM, GroqRateLimitError
+from .groq_quota import GROQ_MODEL_PRIORITY, GroqModelStatus, GroqQuotaManager
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+
+if TYPE_CHECKING:
+ from .base import LLMRequest, LLMResponse
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "GroqProviderRegistry",
+ "GroqAllModelsExhaustedError",
+ "GROQ_MODEL_PRIORITY",
+ "GROQ_PROVIDER_TIMEOUT_MS",
+]
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Default timeout for Groq provider requests in milliseconds
+# 30 seconds is a reasonable balance for Groq's fast inference
+GROQ_PROVIDER_TIMEOUT_MS: int = 30_000
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
+
+
+class GroqAllModelsExhaustedError(Exception):
+ """Exception raised when all Groq models are rate limited.
+
+ This exception is raised when all models in the fallback chain have
+ been exhausted (either rate limited or encountered errors). It provides
+ information about when the next model might become available.
+
+ Attributes:
+ ----------
+ statuses : list[GroqModelStatus]
+ List of GroqModelStatus objects for all models, showing their
+ current availability and cooldown information.
+
+ Properties:
+ ----------
+ min_retry_after : int | None
+ The minimum number of seconds until any model becomes available.
+ Returns None if no models have active cooldowns.
+
+ Example:
+ -------
+ >>> try:
+ ... response = await registry.generate(request)
+ ... except GroqAllModelsExhaustedError as e:
+ ... print(f"All Groq models exhausted")
+ ... if e.min_retry_after:
+ ... print(f"Retry in {e.min_retry_after} seconds")
+ ... for status in e.statuses:
+ ... print(f" {status.model}: {status.reason}")
+
+ Note:
+ ----
+ The min_retry_after property is useful for implementing retry logic.
+ If None is returned, wait for quota reset (usually 1 minute for RPM/TPM).
+
+ """
+
+ def __init__(self, statuses: list[GroqModelStatus], message: str) -> None:
+ """Initialize GroqAllModelsExhaustedError with status information.
+
+ Args:
+ ----
+ statuses: List of GroqModelStatus objects for all models.
+ message: Human-readable error message.
+
+ Example:
+ -------
+ >>> statuses = [manager.get_status(m) for m in GROQ_MODEL_PRIORITY]
+ >>> raise GroqAllModelsExhaustedError(statuses, "All models rate limited")
+
+ """
+ super().__init__(message)
+ self.statuses = statuses
+
+ @property
+ def min_retry_after(self) -> int | None:
+ """Get minimum seconds until any model becomes available.
+
+ Returns:
+ -------
+ Minimum cooldown seconds across all models, or None if no models
+ have active cooldowns.
+
+ Example:
+ -------
+ >>> error = GroqAllModelsExhaustedError(statuses, "Exhausted")
+ >>> if error.min_retry_after:
+ ... await asyncio.sleep(error.min_retry_after)
+
+ """
+ cooldowns = [s.cooldown_seconds for s in self.statuses if s.cooldown_seconds]
+ return min(cooldowns) if cooldowns else None
+
+
+# =============================================================================
+# GroqProviderRegistry Class
+# =============================================================================
+
+
+class GroqProviderRegistry:
+ """Registry for managing Groq LLM model instances with automatic fallback.
+
+ This class manages multiple GroqLLM instances and provides automatic
+ fallback when rate limits are hit. It tracks usage via GroqQuotaManager
+ and creates model instances lazily on first use.
+
+ The registry follows a priority-based fallback strategy:
+ 1. Try the preferred model (if specified and available)
+ 2. If unavailable, try models in GROQ_MODEL_PRIORITY order
+ 3. On rate limit (429), record the limit and try next model
+ 4. On timeout, try next model
+ 5. If all models fail, raise GroqAllModelsExhaustedError
+
+ Attributes:
+ ----------
+ is_available : bool
+ True if any Groq model is currently available for requests.
+
+ Methods:
+ -------
+ get_model(model: str) -> GroqLLM
+ Get or create a Groq model instance (lazy loading).
+
+ get_available_model(preferred: str | None = None) -> GroqLLM | None
+ Get the first available Groq model.
+
+ generate(request: LLMRequest, preferred_model: str | None = None)
+ Generate a response with automatic fallback on rate limits.
+
+ stream(request: LLMRequest, preferred_model: str | None = None)
+ Stream a response with automatic fallback on rate limits.
+
+ get_quota_status() -> list[GroqModelStatus]
+ Get quota status for all Groq models.
+
+ Thread Safety:
+ Model creation is protected by a lock. Multiple threads can safely
+ call get_model() concurrently; only one instance per model is created.
+
+ Example:
+ -------
+ >>> registry = GroqProviderRegistry(api_key="your-key")
+ >>>
+ >>> # Simple usage - auto-selects best available model
+ >>> response = await registry.generate(request)
+ >>>
+ >>> # Prefer a specific model
+ >>> response = await registry.generate(
+ ... request, preferred_model="llama-3.3-70b-versatile"
+ ... )
+ >>>
+ >>> # Check quota status
+ >>> for status in registry.get_quota_status():
+ ... print(f"{status.model}: {status.is_available}")
+
+ Note:
+ ----
+ The registry shares a single GroqQuotaManager across all models. Usage
+ is recorded automatically after successful requests.
+
+ """
+
+ def __init__(
+ self,
+ api_key: str | None,
+ timeout_ms: int = GROQ_PROVIDER_TIMEOUT_MS,
+ ) -> None:
+ """Initialize the GroqProviderRegistry with API key and configuration.
+
+ Creates a new GroqProviderRegistry with the specified API key. Models
+ are NOT created at initialization; they are lazily loaded on first use.
+
+ Args:
+ ----
+ api_key: Groq API key for authentication. Must be a non-empty
+ string. This key is used for all models in the registry.
+ timeout_ms: Request timeout in milliseconds. Defaults to 30000
+ (30 seconds). Applied to all model instances.
+
+ Raises:
+ ------
+ ValueError: If api_key is None, empty, or whitespace-only.
+
+ Example:
+ -------
+ >>> # Basic initialization
+ >>> registry = GroqProviderRegistry(api_key="your-api-key")
+ >>>
+ >>> # Custom timeout
+ >>> registry = GroqProviderRegistry(
+ ... api_key="your-api-key",
+ ... timeout_ms=60000, # 60 seconds
+ ... )
+
+ Note:
+ ----
+ The API key is validated for non-empty format but not for validity.
+ Actual authentication happens on first API call.
+
+ """
+ # Validate api_key is not empty
+ if api_key is None or not api_key.strip():
+ msg = "api_key cannot be empty"
+ raise ValueError(msg)
+
+ # Store configuration
+ self._api_key: str = api_key.strip()
+ self._timeout_ms: int = timeout_ms
+
+ # Initialize model cache (lazy loading)
+ self._models: dict[str, GroqLLM] = {}
+
+ # Lock for thread-safe model creation
+ self._lock: threading.Lock = threading.Lock()
+
+ # Initialize quota manager for tracking usage
+ self._quota_manager: GroqQuotaManager = GroqQuotaManager()
+
+ def get_model(self, model: str) -> GroqLLM:
+ """Get or create a GroqLLM instance for the specified model.
+
+ This method implements lazy loading of model instances. The first
+ call for a model creates the instance; subsequent calls return the
+ cached instance. Thread-safe: only one instance is created per model.
+
+ Args:
+ ----
+ model: Model name (e.g., "openai/gpt-oss-120b").
+ Must be one of the models in GROQ_MODEL_PRIORITY.
+
+ Returns:
+ -------
+ GroqLLM instance configured for the specified model.
+
+ Raises:
+ ------
+ KeyError: If the model is not in GROQ_MODEL_PRIORITY.
+
+ Example:
+ -------
+ >>> registry = GroqProviderRegistry(api_key="key")
+ >>> model = registry.get_model("openai/gpt-oss-120b")
+ >>> model.model_name
+ 'openai/gpt-oss-120b'
+
+ """
+ # Validate model name
+ if model not in GROQ_MODEL_PRIORITY:
+ msg = f"Unknown model: {model!r}. Supported models: {GROQ_MODEL_PRIORITY}"
+ raise KeyError(msg)
+
+ # Fast path: check if model already exists
+ if model in self._models:
+ return self._models[model]
+
+ # Slow path: create model with lock (thread-safe)
+ with self._lock:
+ # Double-check pattern
+ if model in self._models:
+ return self._models[model]
+
+ # Create new model instance
+ llm = GroqLLM(
+ api_key=self._api_key,
+ model=model,
+ timeout_ms=self._timeout_ms,
+ )
+
+ # Cache the instance
+ self._models[model] = llm
+
+ return llm
+
+ def get_available_model(self, preferred: str | None = None) -> GroqLLM | None:
+ """Get the first available Groq model.
+
+ Checks model availability using the GroqQuotaManager and returns the
+ first available model. If a preferred model is specified and available,
+ it is returned; otherwise, models are checked in GROQ_MODEL_PRIORITY order.
+
+ Args:
+ ----
+ preferred: Optional preferred model name. If specified and the
+ model is available, it will be returned.
+
+ Returns:
+ -------
+ GroqLLM instance for the first available model, or None if
+ all models are exhausted.
+
+ Example:
+ -------
+ >>> model = registry.get_available_model()
+ >>> if model:
+ ... print(f"Using {model.model_name}")
+
+ """
+ # Try preferred model first if specified and available
+ if (
+ preferred is not None
+ and preferred in GROQ_MODEL_PRIORITY
+ and self._quota_manager.is_available(preferred)
+ ):
+ return self.get_model(preferred)
+
+ # Fall back to priority order
+ for model_name in GROQ_MODEL_PRIORITY:
+ if self._quota_manager.is_available(model_name):
+ return self.get_model(model_name)
+
+ return None
+
+ async def generate(
+ self,
+ request: LLMRequest,
+ preferred_model: str | None = None,
+ ) -> LLMResponse:
+ """Generate a response with automatic fallback on rate limits.
+
+ Attempts to generate a response using the preferred model (if specified)
+ or the first available model. If a rate limit (429) or timeout occurs,
+ automatically falls back to the next available model in priority order.
+
+ Args:
+ ----
+ request: The LLMRequest containing query, context, and parameters.
+ preferred_model: Optional preferred model name. If specified and
+ available, it will be tried first.
+
+ Returns:
+ -------
+ LLMResponse with generated content and metadata.
+
+ Raises:
+ ------
+ GroqAllModelsExhaustedError: If all models are rate limited or failed.
+ TypeError: If request is not an LLMRequest instance.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for..."],
+ ... )
+ >>> try:
+ ... response = await registry.generate(request)
+ ... print(response.content)
+ ... except GroqAllModelsExhaustedError as e:
+ ... print(f"Retry in {e.min_retry_after} seconds")
+
+ Note:
+ ----
+ Usage is recorded automatically after successful requests.
+ Rate limits are recorded to update cooldown periods.
+
+ """
+ # Import here to avoid circular imports
+ from .base import LLMRequest as LLMRequestClass
+
+ # Validate input type
+ if not isinstance(request, LLMRequestClass):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Build list of models to try
+ models_to_try: list[str] = []
+
+ if (
+ preferred_model is not None
+ and preferred_model in GROQ_MODEL_PRIORITY
+ and self._quota_manager.is_available(preferred_model)
+ ):
+ models_to_try.append(preferred_model)
+
+ # Add remaining models in priority order
+ for model_name in GROQ_MODEL_PRIORITY:
+ if model_name not in models_to_try and self._quota_manager.is_available(
+ model_name
+ ):
+ models_to_try.append(model_name)
+
+ # Try each model until one succeeds
+ for model_name in models_to_try:
+ try:
+ llm = self.get_model(model_name)
+ response = await llm.generate(request)
+
+ except GroqRateLimitError as e:
+ # Record rate limit and continue to next model
+ self._quota_manager.record_rate_limit(model_name, e.retry_after)
+ continue
+
+ except TimeoutError:
+ # Timeout - try next model (no cooldown recorded)
+ continue
+
+ else:
+ # Record successful usage and return response
+ self._quota_manager.record_usage(
+ model_name, tokens=response.tokens_used
+ )
+ return response
+
+ # All models exhausted
+ statuses = self._quota_manager.get_all_status()
+ msg = "All Groq models are rate limited or unavailable"
+ raise GroqAllModelsExhaustedError(statuses, msg)
+
+ async def stream(
+ self,
+ request: LLMRequest,
+ preferred_model: str | None = None,
+ ) -> AsyncIterator[str]:
+ """Stream a response with automatic fallback on rate limits.
+
+ Attempts to stream a response using the preferred model (if specified)
+ or the first available model. If a rate limit (429) or timeout occurs
+ before streaming starts, automatically falls back to the next available
+ model in priority order.
+
+ Args:
+ ----
+ request: The LLMRequest containing query, context, and parameters.
+ preferred_model: Optional preferred model name.
+
+ Yields:
+ ------
+ String chunks of the generated response as they become available.
+
+ Raises:
+ ------
+ GroqAllModelsExhaustedError: If all models are rate limited or failed.
+ TypeError: If request is not an LLMRequest instance.
+
+ Example:
+ -------
+ >>> request = LLMRequest(query="What is PMV?", context=[...])
+ >>> try:
+ ... async for chunk in registry.stream(request):
+ ... print(chunk, end="", flush=True)
+ ... except GroqAllModelsExhaustedError as e:
+ ... print(f"Retry in {e.min_retry_after} seconds")
+
+ Note:
+ ----
+ Rate limits encountered before streaming starts trigger fallback.
+
+ """
+ # Import here to avoid circular imports
+ from .base import LLMRequest as LLMRequestClass
+
+ # Validate input type
+ if not isinstance(request, LLMRequestClass):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Build list of models to try
+ models_to_try: list[str] = []
+
+ if (
+ preferred_model is not None
+ and preferred_model in GROQ_MODEL_PRIORITY
+ and self._quota_manager.is_available(preferred_model)
+ ):
+ models_to_try.append(preferred_model)
+
+ for model_name in GROQ_MODEL_PRIORITY:
+ if model_name not in models_to_try and self._quota_manager.is_available(
+ model_name
+ ):
+ models_to_try.append(model_name)
+
+ # Try each model until one succeeds
+ for model_name in models_to_try:
+ try:
+ llm = self.get_model(model_name)
+
+ async for chunk in llm.stream(request):
+ yield chunk
+
+ except GroqRateLimitError as e:
+ # Record rate limit and continue to next model
+ self._quota_manager.record_rate_limit(model_name, e.retry_after)
+ continue
+
+ except TimeoutError:
+ # Timeout - try next model
+ continue
+
+ else:
+ # Stream completed successfully
+ return
+
+ # All models exhausted
+ statuses = self._quota_manager.get_all_status()
+ msg = "All Groq models are rate limited or unavailable"
+ raise GroqAllModelsExhaustedError(statuses, msg)
+
+ def get_quota_status(self) -> list[GroqModelStatus]:
+ """Get quota status for all Groq models.
+
+ Returns the current availability and quota information for all
+ models in GROQ_MODEL_PRIORITY.
+
+ Returns:
+ -------
+ List of GroqModelStatus objects, one for each model.
+
+ Example:
+ -------
+ >>> for status in registry.get_quota_status():
+ ... print(f"{status.model}:")
+ ... print(f" Available: {status.is_available}")
+ ... print(f" RPM remaining: {status.requests_remaining_minute}")
+
+ """
+ return self._quota_manager.get_all_status()
+
+ @property
+ def is_available(self) -> bool:
+ """Check if any Groq model is available for requests.
+
+ Returns True if at least one model in GROQ_MODEL_PRIORITY is available
+ according to the GroqQuotaManager.
+
+ Returns:
+ -------
+ True if any model is available, False if all are exhausted.
+
+ Example:
+ -------
+ >>> if registry.is_available:
+ ... response = await registry.generate(request)
+
+ """
+ return any(
+ self._quota_manager.is_available(model_name)
+ for model_name in GROQ_MODEL_PRIORITY
+ )
diff --git a/src/rag_chatbot/llm/prompts.py b/src/rag_chatbot/llm/prompts.py
new file mode 100644
index 0000000000000000000000000000000000000000..c18016a88de172235d264333e3787d03ca5b5def
--- /dev/null
+++ b/src/rag_chatbot/llm/prompts.py
@@ -0,0 +1,180 @@
+r"""System prompts and chat template configuration for LLM providers.
+
+This module centralizes all system prompts, chat templates, and related
+configuration constants for the RAG chatbot. Having prompts in a dedicated
+module enables:
+ - Easy modification without touching provider code
+ - Consistent prompts across all LLM providers
+ - Clear separation of concerns
+ - Testability of prompt content
+
+Components:
+ - THERMAL_COMFORT_SYSTEM_PROMPT: Main system prompt for the chatbot
+ - MAX_HISTORY_MESSAGES: Maximum conversation history to include
+ - Role mapping utilities for provider-specific formats
+
+Design Principles:
+ - Prompts are immutable constants (no runtime modification)
+ - Provider-agnostic prompt text (formatting done in providers)
+ - Comprehensive domain coverage for thermal comfort
+
+Example:
+-------
+ >>> from rag_chatbot.llm.prompts import (
+ ... THERMAL_COMFORT_SYSTEM_PROMPT,
+ ... MAX_HISTORY_MESSAGES,
+ ... )
+ >>> # Use in provider implementation
+ >>> system_content = THERMAL_COMFORT_SYSTEM_PROMPT
+ >>> if context:
+ ... system_content += f"\n\n**Retrieved Context:**\n{context}"
+
+"""
+
+from __future__ import annotations
+
+from typing import Final
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "THERMAL_COMFORT_SYSTEM_PROMPT",
+ "MAX_HISTORY_MESSAGES",
+ "GREETING_PATTERNS",
+ "FAREWELL_PATTERNS",
+]
+
+
+# =============================================================================
+# History Configuration
+# =============================================================================
+
+# Maximum number of conversation history messages to include in the prompt.
+# This limit prevents context window overflow while maintaining conversation
+# continuity. With an average of ~100 tokens per message, 10 messages uses
+# approximately 1000 tokens of context budget.
+#
+# Rationale for 10 messages:
+# - Covers typical 5-turn conversation (5 user + 5 assistant)
+# - Leaves sufficient room for retrieved context (~2000 tokens)
+# - Works within most model context windows (8K-128K tokens)
+MAX_HISTORY_MESSAGES: Final[int] = 10
+
+
+# =============================================================================
+# Social Interaction Patterns
+# =============================================================================
+# These patterns help identify greetings and farewells for appropriate
+# conversational handling. The patterns are case-insensitive substrings.
+
+# Common greeting patterns to detect friendly introductions
+GREETING_PATTERNS: Final[tuple[str, ...]] = (
+ "hello",
+ "hi",
+ "hey",
+ "good morning",
+ "good afternoon",
+ "good evening",
+ "greetings",
+ "howdy",
+ "what's up",
+ "whats up",
+)
+
+# Common farewell patterns to detect conversation endings
+FAREWELL_PATTERNS: Final[tuple[str, ...]] = (
+ "bye",
+ "goodbye",
+ "good bye",
+ "see you",
+ "take care",
+ "thanks",
+ "thank you",
+ "cheers",
+ "later",
+ "have a good",
+ "have a nice",
+)
+
+
+# =============================================================================
+# System Prompt
+# =============================================================================
+# The main system prompt that defines the chatbot's persona, expertise,
+# response guidelines, and conversation style. This prompt is used by all
+# LLM providers and should be provider-agnostic.
+#
+# Structure:
+# 1. Identity and knowledge sources
+# 2. Domain expertise areas
+# 3. Response guidelines (anti-hallucination focus)
+# 4. Conversation style (social interactions)
+# 5. Context introduction
+
+THERMAL_COMFORT_SYSTEM_PROMPT: Final[str] = """\
+You are the pythermalcomfort Assistant, a specialized AI for thermal comfort \
+engineering. Your knowledge comes from peer-reviewed research articles, \
+international standards (ASHRAE 55, ISO 7730, EN 16798), and the \
+pythermalcomfort Python library documentation.
+
+**Your Expertise:**
+- Thermal comfort models: PMV (Predicted Mean Vote), PPD (Predicted \
+Percentage Dissatisfied), SET (Standard Effective Temperature), UTCI \
+(Universal Thermal Climate Index), adaptive comfort models, two-node models, \
+and physiological models
+- Environmental parameters: air temperature (ta), mean radiant temperature \
+(tr), air velocity (v), relative humidity (rh), operative temperature
+- Personal parameters: metabolic rate (met), clothing insulation (clo), \
+body surface area
+- Standards and guidelines: ASHRAE Standard 55, ISO 7730, EN 16798-1, \
+ISO 7243, ISO 7933
+- The pythermalcomfort Python library: available functions, required \
+parameters, return values, and practical usage examples
+
+**Response Guidelines:**
+1. ONLY answer questions using information from the provided context. If the \
+context does not contain enough information to fully answer a question, \
+clearly state: "I don't have enough information in my knowledge base to \
+answer this question accurately. Could you try rephrasing your question or \
+ask about a specific aspect of thermal comfort?"
+
+2. When citing information, reference the source document and page number \
+when available (e.g., "According to ASHRAE Standard 55 (p. 12)...").
+
+3. For code examples, use accurate pythermalcomfort library syntax. Always \
+include necessary imports and realistic parameter values with proper units.
+
+4. Be precise with technical terminology and units:
+ - Temperature: °C (degrees Celsius)
+ - Air velocity: m/s (meters per second)
+ - Metabolic rate: met (1 met = 58.2 W/m²)
+ - Clothing insulation: clo (1 clo = 0.155 m²·K/W)
+ - Relative humidity: % (percentage)
+
+5. If a question is ambiguous, ask for clarification about the specific \
+standard, thermal comfort model, environmental conditions, or use case the \
+user is interested in.
+
+6. When explaining thermal comfort concepts, provide context about why they \
+matter and how they relate to human comfort, health, and building design.
+
+**Conversation Style:**
+- For greetings (hi, hello, good morning, etc.): Respond warmly and briefly \
+introduce yourself. Mention that you specialize in thermal comfort and the \
+pythermalcomfort library, and invite the user to ask questions.
+
+- For farewells (bye, thanks, goodbye, etc.): Respond politely, acknowledge \
+any thanks, and offer to help with future thermal comfort questions.
+
+- For off-topic questions: Politely explain that you specialize in thermal \
+comfort topics and the pythermalcomfort library, then offer to help with \
+related questions.
+
+- Maintain a professional yet approachable tone suitable for engineers, \
+researchers, and students working with thermal comfort.
+
+**Context Information:**
+The following context chunks have been retrieved from the knowledge base to \
+help answer the user's question. Use this information as the primary source \
+for your response."""
diff --git a/src/rag_chatbot/llm/quota.py b/src/rag_chatbot/llm/quota.py
index 38c2605b2f4b31eff24037f1cd6a2281eaa4d132..f8c678af08f6831576178404a6b8739bec2c965f 100644
--- a/src/rag_chatbot/llm/quota.py
+++ b/src/rag_chatbot/llm/quota.py
@@ -1,185 +1,839 @@
-"""Quota management for LLM providers.
+"""Quota management for Google LLM providers.
This module provides the QuotaManager class for tracking API usage
-across LLM providers and determining when to fall back to alternative
-providers. The manager tracks:
- - Request counts per time window
- - Token usage per provider
- - Error rates and availability
+across Google LLM providers and determining model availability based
+on free tier rate limits.
-Lazy Loading:
- No heavy dependencies - this module loads quickly.
+Supported Models and Rate Limits (Free Tier):
+ - gemini-2.5-flash-lite: 10 RPM, 250,000 TPM, 20 RPD
+ - gemini-2.5-flash: 5 RPM, 250,000 TPM, 20 RPD
+ - gemini-3-flash: 5 RPM, 250,000 TPM, 20 RPD
+ - gemma-3-27b-it: 30 RPM, 15,000 TPM, 14,400 RPD
+
+Components:
+ - ModelQuota: Pydantic model for rate limit configuration (immutable)
+ - ModelUsage: Pydantic model for usage tracking (mutable)
+ - ModelStatus: Pydantic model for availability status (immutable)
+ - QuotaManager: Main class for quota tracking and availability checks
-Note:
-----
- This is a placeholder that will be fully implemented in Step 3.1.
+Thread Safety:
+ All counter updates are protected by threading.Lock to ensure
+ thread-safe operation in concurrent environments.
+
+Lazy Loading:
+ No heavy dependencies - this module loads quickly without
+ importing torch, transformers, or other heavy packages.
+
+Example:
+-------
+ >>> from rag_chatbot.llm.quota import QuotaManager
+ >>>
+ >>> # Create a quota manager
+ >>> manager = QuotaManager()
+ >>>
+ >>> # Check if a model is available
+ >>> if manager.is_available("gemini-2.5-flash"):
+ ... # Record usage after successful request
+ ... manager.record_usage("gemini-2.5-flash", tokens=500)
+ ...
+ >>> # Handle rate limit (429) errors
+ >>> manager.record_rate_limit("gemini-2.5-flash", retry_after=60)
+ >>>
+ >>> # Get detailed status for a model
+ >>> status = manager.get_status("gemini-2.5-flash")
+ >>> print(f"Available: {status.is_available}")
+ >>> print(f"Requests remaining: {status.requests_remaining_minute}")
"""
from __future__ import annotations
+import threading
+from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
+from pydantic import BaseModel, ConfigDict, Field
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
+
if TYPE_CHECKING:
- pass # Future type imports will go here
+ pass # No type-only imports needed currently
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["QuotaManager", "ProviderStatus"]
+__all__: list[str] = [
+ "ModelQuota",
+ "ModelUsage",
+ "ModelStatus",
+ "QuotaManager",
+]
-class ProviderStatus: # pragma: no cover
- """Represents the current status of an LLM provider.
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Default cooldown period in seconds when no Retry-After header is provided
+# 15 minutes is a reasonable default for rate limit recovery
+_DEFAULT_COOLDOWN_SECONDS: int = 900
+
+# Model names for Google LLMs (Free Tier)
+_MODEL_GEMINI_25_FLASH_LITE: str = "gemini-2.5-flash-lite"
+_MODEL_GEMINI_25_FLASH: str = "gemini-2.5-flash"
+_MODEL_GEMINI_3_FLASH: str = "gemini-3-flash"
+_MODEL_GEMMA_3_27B_IT: str = "gemma-3-27b-it"
+
+
+# =============================================================================
+# Pydantic Models
+# =============================================================================
+
+
+class ModelQuota(BaseModel):
+ """Rate limit configuration for a Google LLM model.
+
+ This immutable model stores the rate limits for a specific model.
+ All values represent the maximum allowed usage per time window.
Attributes:
----------
- provider_name: Name of the provider.
- is_available: Whether the provider is currently available.
- requests_remaining: Estimated requests remaining in current window.
- tokens_remaining: Estimated tokens remaining in current window.
- error_rate: Recent error rate (0.0 to 1.0).
+ rpm : int
+ Requests Per Minute limit. Maximum number of API requests
+ allowed within a 60-second sliding window.
+
+ tpm : int
+ Tokens Per Minute limit. Maximum number of tokens (input + output)
+ allowed within a 60-second sliding window.
+
+ rpd : int
+ Requests Per Day limit. Maximum number of API requests
+ allowed within a 24-hour period (resets at midnight UTC).
+
+ Example:
+ -------
+ >>> quota = ModelQuota(rpm=10, tpm=250_000, rpd=20)
+ >>> quota.rpm
+ 10
Note:
----
- This class will be converted to a Pydantic model in Step 3.1.
+ This model is frozen (immutable) since quota limits should not
+ change after initialization. Changes require creating a new instance.
"""
- def __init__( # noqa: PLR0913
- self,
- provider_name: str,
- is_available: bool,
- requests_remaining: int,
- tokens_remaining: int,
- error_rate: float,
- ) -> None:
- """Initialize provider status.
+ # Model configuration for Pydantic v2
+ model_config = ConfigDict(
+ # Make the model immutable for thread-safety
+ frozen=True,
+ # Forbid extra fields to catch typos
+ extra="forbid",
+ # Validate default values
+ validate_default=True,
+ )
+
+ # Rate limit fields with validation
+ rpm: int = Field(
+ ..., # Required field
+ gt=0, # Must be positive (greater than 0)
+ description="Requests Per Minute limit",
+ )
+
+ tpm: int = Field(
+ ..., # Required field
+ gt=0, # Must be positive
+ description="Tokens Per Minute limit",
+ )
+
+ rpd: int = Field(
+ ..., # Required field
+ gt=0, # Must be positive
+ description="Requests Per Day limit",
+ )
+
+
+class ModelUsage(BaseModel):
+ """Usage tracking for a Google LLM model.
+
+ This mutable model tracks current usage within rate limit windows.
+ Counters are reset periodically (every minute for RPM/TPM, daily for RPD).
- Args:
- ----
- provider_name: Name of the provider.
- is_available: Whether the provider is available.
- requests_remaining: Estimated requests remaining.
- tokens_remaining: Estimated tokens remaining.
- error_rate: Recent error rate.
+ Attributes:
+ ----------
+ requests_this_minute : int
+ Number of requests made in the current minute window.
+ Reset when minute_started_at is more than 60 seconds old.
- Raises:
- ------
- NotImplementedError: ProviderStatus will be implemented in Step 3.1.
+ tokens_this_minute : int
+ Number of tokens used in the current minute window.
+ Reset when minute_started_at is more than 60 seconds old.
- """
- raise NotImplementedError("ProviderStatus will be implemented in Step 3.1")
+ requests_today : int
+ Number of requests made today (since midnight UTC).
+ Reset when day_started_at is a different day.
+
+ minute_started_at : datetime | None
+ Timestamp of first request in current minute window.
+ None if no requests have been made in current window.
+ day_started_at : datetime | None
+ Timestamp of first request today.
+ None if no requests have been made today.
-class QuotaManager: # pragma: no cover
- """Manage API quotas and provider fallback logic.
+ cooldown_until : datetime | None
+ Timestamp when cooldown period ends (after 429 error).
+ None if model is not in cooldown.
+
+ Example:
+ -------
+ >>> usage = ModelUsage(
+ ... requests_this_minute=0,
+ ... tokens_this_minute=0,
+ ... requests_today=0,
+ ... minute_started_at=None,
+ ... day_started_at=None,
+ ... cooldown_until=None,
+ ... )
+ >>> usage.requests_this_minute = 5
+ >>> usage.requests_this_minute
+ 5
- This class tracks API usage across multiple LLM providers and
- determines the optimal provider to use based on:
- - Current quota availability
- - Recent error rates
- - Provider priority order
+ Note:
+ ----
+ This model is mutable to allow counter updates. Thread safety
+ is handled by the QuotaManager's lock, not by this model.
- The manager implements automatic fallback when providers are
- unavailable or over quota.
+ """
- Provider Priority (default):
- 1. Gemini - Primary provider
- 2. Groq - Fast inference fallback
- 3. DeepSeek - Cost-effective fallback
+ # Model configuration - NOT frozen since we need to update counters
+ model_config = ConfigDict(
+ # Allow mutation for counter updates
+ frozen=False,
+ # Forbid extra fields
+ extra="forbid",
+ # Validate default values
+ validate_default=True,
+ )
+
+ # Usage tracking fields
+ requests_this_minute: int = Field(
+ default=0,
+ ge=0, # Must be non-negative
+ description="Number of requests made in current minute window",
+ )
+
+ tokens_this_minute: int = Field(
+ default=0,
+ ge=0, # Must be non-negative
+ description="Number of tokens used in current minute window",
+ )
+
+ requests_today: int = Field(
+ default=0,
+ ge=0, # Must be non-negative
+ description="Number of requests made today (since midnight UTC)",
+ )
+
+ minute_started_at: datetime | None = Field(
+ default=None,
+ description="Timestamp of first request in current minute window",
+ )
+
+ day_started_at: datetime | None = Field(
+ default=None,
+ description="Timestamp of first request today",
+ )
+
+ cooldown_until: datetime | None = Field(
+ default=None,
+ description="Timestamp when cooldown period ends (after 429 error)",
+ )
+
+
+class ModelStatus(BaseModel):
+ """Availability status for a Google LLM model.
+
+ This immutable model provides a snapshot of model availability
+ and remaining quota. Used for monitoring and decision making.
Attributes:
----------
- providers: List of provider names in priority order.
- window_seconds: Time window for quota tracking.
+ model : str
+ Model name identifier (e.g., "gemini-2.5-flash").
+
+ is_available : bool
+ Whether the model is currently available for requests.
+ False if any quota is exhausted or model is in cooldown.
+
+ requests_remaining_minute : int
+ Number of requests remaining in current minute window.
+ May be 0 or negative if quota is exhausted.
+
+ tokens_remaining_minute : int
+ Number of tokens remaining in current minute window.
+ May be 0 or negative if quota is exhausted.
+
+ requests_remaining_day : int
+ Number of requests remaining today.
+ May be 0 or negative if quota is exhausted.
+
+ cooldown_seconds : int | None
+ Seconds remaining in cooldown period (after 429 error).
+ None if model is not in cooldown.
+
+ reason : str | None
+ Human-readable reason why model is unavailable.
+ None if model is available.
+
+ Example:
+ -------
+ >>> status = ModelStatus(
+ ... model="gemini-2.5-flash",
+ ... is_available=True,
+ ... requests_remaining_minute=5,
+ ... tokens_remaining_minute=250_000,
+ ... requests_remaining_day=20,
+ ... cooldown_seconds=None,
+ ... reason=None,
+ ... )
+ >>> status.is_available
+ True
+
+ """
+
+ # Model configuration - frozen for thread-safety and immutability
+ model_config = ConfigDict(
+ # Make the model immutable
+ frozen=True,
+ # Forbid extra fields
+ extra="forbid",
+ # Validate default values
+ validate_default=True,
+ )
+
+ # Status fields
+ model: str = Field(
+ ..., # Required field
+ min_length=1,
+ description="Model name identifier",
+ )
+
+ is_available: bool = Field(
+ ..., # Required field
+ description="Whether the model is currently available",
+ )
+
+ requests_remaining_minute: int = Field(
+ ..., # Required field
+ description="Requests remaining in current minute window",
+ )
+
+ tokens_remaining_minute: int = Field(
+ ..., # Required field
+ description="Tokens remaining in current minute window",
+ )
+
+ requests_remaining_day: int = Field(
+ ..., # Required field
+ description="Requests remaining today",
+ )
+
+ cooldown_seconds: int | None = Field(
+ default=None,
+ description="Seconds remaining in cooldown period",
+ )
+
+ reason: str | None = Field(
+ default=None,
+ description="Human-readable reason why model is unavailable",
+ )
+
+
+# =============================================================================
+# QuotaManager Class
+# =============================================================================
+
+
+class QuotaManager:
+ """Manage API quotas for Google LLM providers.
+
+ This class tracks API usage across multiple Google LLM models and
+ determines availability based on free tier rate limits. It provides:
+ - Per-model quota tracking (RPM, TPM, RPD)
+ - Cooldown handling for 429 rate limit errors
+ - Thread-safe counter updates
+ - Status reporting for monitoring
+
+ The manager maintains separate counters for each model and automatically
+ handles minute/day window boundaries. Counters must be reset explicitly
+ using reset_minute_counters() and reset_day_counters() methods.
+
+ Supported Models (Free Tier):
+ - gemini-2.5-flash-lite: 10 RPM, 250,000 TPM, 20 RPD
+ - gemini-2.5-flash: 5 RPM, 250,000 TPM, 20 RPD
+ - gemini-3-flash: 5 RPM, 250,000 TPM, 20 RPD
+ - gemma-3-27b-it: 30 RPM, 15,000 TPM, 14,400 RPD
+
+ Thread Safety:
+ All public methods are thread-safe. Counter updates are protected
+ by an internal threading.Lock to prevent race conditions.
Example:
-------
>>> manager = QuotaManager()
- >>> provider = manager.get_available_provider()
- >>> manager.record_usage(provider, tokens=500)
+ >>>
+ >>> # Check availability before making request
+ >>> if manager.is_available("gemini-2.5-flash"):
+ ... # Make API request...
+ ... response = await llm.generate(request)
+ ... # Record usage after success
+ ... manager.record_usage("gemini-2.5-flash", tokens=response.tokens_used)
+ ... else:
+ ... # Try fallback model
+ ... status = manager.get_status("gemini-2.5-flash")
+ ... print(f"Model unavailable: {status.reason}")
+ >>>
+ >>> # Handle 429 rate limit errors
+ >>> try:
+ ... response = await llm.generate(request)
+ ... except RateLimitError as e:
+ ... manager.record_rate_limit("gemini-2.5-flash", e.retry_after)
Note:
----
- This class will be fully implemented in Phase 3 (Step 3.1).
+ The manager does not automatically reset counters. In production,
+ you should call reset_minute_counters() every 60 seconds and
+ reset_day_counters() at midnight UTC using a scheduler.
"""
- def __init__(
- self,
- providers: list[str] | None = None,
- window_seconds: int = 60,
- ) -> None:
- """Initialize the quota manager.
+ def __init__(self) -> None:
+ """Initialize the QuotaManager with default quotas for all models.
+
+ Creates quota configurations and usage trackers for all supported
+ Google LLM models. All models start with zero usage and full
+ quota availability.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> manager.is_available("gemini-2.5-flash")
+ True
+
+ """
+ # Thread lock for protecting counter updates
+ # All public methods acquire this lock before modifying state
+ self._lock: threading.Lock = threading.Lock()
+
+ # Initialize quota configurations for all supported models
+ # These are immutable and define the rate limits for each model
+ self._quotas: dict[str, ModelQuota] = {
+ _MODEL_GEMINI_25_FLASH_LITE: ModelQuota(
+ rpm=10,
+ tpm=250_000,
+ rpd=20,
+ ),
+ _MODEL_GEMINI_25_FLASH: ModelQuota(
+ rpm=5,
+ tpm=250_000,
+ rpd=20,
+ ),
+ _MODEL_GEMINI_3_FLASH: ModelQuota(
+ rpm=5,
+ tpm=250_000,
+ rpd=20,
+ ),
+ _MODEL_GEMMA_3_27B_IT: ModelQuota(
+ rpm=30,
+ tpm=15_000,
+ rpd=14_400,
+ ),
+ }
+
+ # Initialize usage trackers for all supported models
+ # These are mutable and track current usage within rate limit windows
+ self._usage: dict[str, ModelUsage] = {
+ model: ModelUsage(
+ requests_this_minute=0,
+ tokens_this_minute=0,
+ requests_today=0,
+ minute_started_at=None,
+ day_started_at=None,
+ cooldown_until=None,
+ )
+ for model in self._quotas
+ }
+
+ def _validate_model(self, model: str) -> None:
+ """Validate that the model is supported.
Args:
----
- providers: List of provider names in priority order.
- Defaults to ['gemini', 'groq', 'deepseek'].
- window_seconds: Time window for quota tracking in seconds.
- Defaults to 60.
+ model: Model name to validate.
Raises:
------
- NotImplementedError: QuotaManager will be implemented in Step 3.1.
+ KeyError: If the model is not supported.
"""
- self._providers = providers or ["gemini", "groq", "deepseek"]
- self._window_seconds = window_seconds
- raise NotImplementedError("QuotaManager will be implemented in Step 3.1")
+ if model not in self._quotas:
+ supported = list(self._quotas.keys())
+ msg = f"Unknown model: {model!r}. Supported models: {supported}"
+ raise KeyError(msg)
- def get_available_provider(self) -> str:
- """Get the highest priority available provider.
+ def _get_now(self) -> datetime:
+ """Get the current UTC datetime.
+
+ This method is separated to allow for easier mocking in tests.
Returns
-------
- Name of the best available provider.
+ Current datetime in UTC timezone.
+
+ """
+ return datetime.now(UTC)
+
+ def is_available(self, model: str) -> bool:
+ """Check if a model is available for requests.
- Raises
+ Checks all quota dimensions (RPM, TPM, RPD) and cooldown status
+ to determine if the model can accept new requests. A model is
+ available only if ALL of the following conditions are met:
+ - requests_this_minute < quota.rpm
+ - tokens_this_minute < quota.tpm
+ - requests_today < quota.rpd
+ - cooldown_until is None or now > cooldown_until
+
+ Args:
+ ----
+ model: Model name to check (e.g., "gemini-2.5-flash").
+
+ Returns:
+ -------
+ True if the model is available, False otherwise.
+
+ Raises:
------
- NotImplementedError: Method will be implemented in Step 3.1.
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> manager.is_available("gemini-2.5-flash")
+ True
+ >>> # After exhausting quota...
+ >>> manager.is_available("gemini-2.5-flash")
+ False
"""
- raise NotImplementedError(
- "get_available_provider() will be implemented in Step 3.1"
- )
+ # Validate model name before acquiring lock
+ self._validate_model(model)
+
+ # Acquire lock for thread-safe read of usage state
+ with self._lock:
+ quota = self._quotas[model]
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Check cooldown status first (most likely reason for unavailability)
+ if usage.cooldown_until is not None and now < usage.cooldown_until:
+ return False
+
+ # Check RPM (Requests Per Minute)
+ if usage.requests_this_minute >= quota.rpm:
+ return False
+
+ # Check TPM (Tokens Per Minute)
+ if usage.tokens_this_minute >= quota.tpm:
+ return False
+
+ # Check RPD (Requests Per Day)
+ if usage.requests_today >= quota.rpd:
+ return False
+
+ # All checks passed - model is available
+ return True
+
+ def record_usage(self, model: str, tokens: int) -> None:
+ """Record a successful API request for a model.
- def record_usage(
- self,
- provider: str,
- tokens: int,
- success: bool = True,
- ) -> None:
- """Record API usage for a provider.
+ Updates all usage counters (RPM, TPM, RPD) for the specified model.
+ This method should be called after a successful API request to
+ track quota consumption.
Args:
----
- provider: Name of the provider used.
- tokens: Number of tokens consumed.
- success: Whether the request succeeded.
+ model: Model name (e.g., "gemini-2.5-flash").
+ tokens: Number of tokens consumed by the request.
Raises:
------
- NotImplementedError: Method will be implemented in Step 3.1.
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> manager.record_usage("gemini-2.5-flash", tokens=500)
+ >>> status = manager.get_status("gemini-2.5-flash")
+ >>> status.requests_remaining_minute
+ 4
+
+ Note:
+ ----
+ This method does not check if quota is available before
+ recording. Call is_available() first if you need to prevent
+ over-quota requests.
"""
- raise NotImplementedError("record_usage() will be implemented in Step 3.1")
+ # Validate model name before acquiring lock
+ self._validate_model(model)
+
+ # Acquire lock for thread-safe counter update
+ with self._lock:
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Set minute window start time if this is first request in window
+ if usage.minute_started_at is None:
+ usage.minute_started_at = now
+
+ # Set day window start time if this is first request today
+ if usage.day_started_at is None:
+ usage.day_started_at = now
+
+ # Increment counters
+ usage.requests_this_minute += 1
+ usage.tokens_this_minute += tokens
+ usage.requests_today += 1
+
+ def record_rate_limit(self, model: str, retry_after: int | None) -> None:
+ """Record a rate limit (429) error for a model.
- def get_status(self, provider: str) -> ProviderStatus:
- """Get current status for a provider.
+ Sets a cooldown period during which the model will be marked
+ as unavailable. The cooldown duration is determined by the
+ retry_after parameter or defaults to 900 seconds (15 minutes).
Args:
----
- provider: Name of the provider.
+ model: Model name (e.g., "gemini-2.5-flash").
+ retry_after: Seconds to wait before retrying, from the
+ Retry-After header. If None, uses default of
+ 900 seconds (15 minutes).
+
+ Raises:
+ ------
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> manager.record_rate_limit("gemini-2.5-flash", retry_after=60)
+ >>> manager.is_available("gemini-2.5-flash")
+ False
+
+ Note:
+ ----
+ Multiple calls to this method will update the cooldown
+ period. If you want to extend an existing cooldown, pass
+ a larger retry_after value.
+
+ """
+ # Validate model name before acquiring lock
+ self._validate_model(model)
+
+ # Use default cooldown if retry_after is None
+ cooldown_seconds = (
+ retry_after if retry_after is not None else _DEFAULT_COOLDOWN_SECONDS
+ )
+
+ # Acquire lock for thread-safe update
+ with self._lock:
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Set cooldown_until to current time + cooldown duration
+ usage.cooldown_until = now + timedelta(seconds=cooldown_seconds)
+
+ def get_status(self, model: str) -> ModelStatus:
+ """Get detailed availability status for a model.
+
+ Returns a snapshot of the model's current quota state including
+ remaining requests/tokens, cooldown status, and a reason for
+ unavailability if applicable.
+
+ Args:
+ ----
+ model: Model name (e.g., "gemini-2.5-flash").
Returns:
-------
- ProviderStatus with current availability info.
+ ModelStatus with current availability information.
Raises:
------
- NotImplementedError: Method will be implemented in Step 3.1.
+ KeyError: If the model is not supported.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> status = manager.get_status("gemini-2.5-flash")
+ >>> status.is_available
+ True
+ >>> status.requests_remaining_minute
+ 5
+
+ """
+ # Validate model name before acquiring lock
+ self._validate_model(model)
+
+ # Acquire lock for thread-safe read
+ with self._lock:
+ quota = self._quotas[model]
+ usage = self._usage[model]
+ now = self._get_now()
+
+ # Calculate remaining quota
+ requests_remaining_minute = quota.rpm - usage.requests_this_minute
+ tokens_remaining_minute = quota.tpm - usage.tokens_this_minute
+ requests_remaining_day = quota.rpd - usage.requests_today
+
+ # Calculate cooldown seconds if in cooldown
+ cooldown_seconds: int | None = None
+ if usage.cooldown_until is not None and now < usage.cooldown_until:
+ delta = usage.cooldown_until - now
+ cooldown_seconds = int(delta.total_seconds())
+
+ # Determine availability and reason
+ is_available = True
+ reason: str | None = None
+
+ # Check cooldown first (highest priority)
+ if cooldown_seconds is not None and cooldown_seconds > 0:
+ is_available = False
+ reason = (
+ f"Model in cooldown for {cooldown_seconds} seconds (rate limit 429)"
+ )
+
+ # Check RPM
+ elif requests_remaining_minute <= 0:
+ is_available = False
+ used = usage.requests_this_minute
+ limit = quota.rpm
+ reason = f"RPM quota exhausted ({used}/{limit} requests this minute)"
+
+ # Check TPM
+ elif tokens_remaining_minute <= 0:
+ is_available = False
+ used = usage.tokens_this_minute
+ reason = f"TPM quota exhausted ({used}/{quota.tpm} tokens this minute)"
+
+ # Check RPD
+ elif requests_remaining_day <= 0:
+ is_available = False
+ used = usage.requests_today
+ reason = f"RPD quota exhausted ({used}/{quota.rpd} requests today)"
+
+ return ModelStatus(
+ model=model,
+ is_available=is_available,
+ requests_remaining_minute=requests_remaining_minute,
+ tokens_remaining_minute=tokens_remaining_minute,
+ requests_remaining_day=requests_remaining_day,
+ cooldown_seconds=cooldown_seconds,
+ reason=reason,
+ )
+
+ def get_all_status(self) -> list[ModelStatus]:
+ """Get availability status for all supported models.
+
+ Returns a list of ModelStatus objects, one for each supported
+ model. Useful for dashboard monitoring and logging.
+
+ Returns:
+ -------
+ List of ModelStatus objects for all supported models.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> statuses = manager.get_all_status()
+ >>> len(statuses)
+ 4
+ >>> for status in statuses:
+ ... print(f"{status.model}: {status.is_available}")
+
+ """
+ # Get status for each model (each call acquires lock separately)
+ return [self.get_status(model) for model in self._quotas]
+
+ def reset_minute_counters(self) -> None:
+ """Reset minute-based counters (RPM, TPM) for all models.
+
+ Resets requests_this_minute, tokens_this_minute, and
+ minute_started_at for all models. This should be called
+ every 60 seconds by a scheduler.
+
+ Note that this does NOT reset daily counters (requests_today).
+ Use reset_day_counters() for daily resets.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> manager.record_usage("gemini-2.5-flash", tokens=100)
+ >>> manager.reset_minute_counters()
+ >>> status = manager.get_status("gemini-2.5-flash")
+ >>> status.requests_remaining_minute
+ 5
+
+ Note:
+ ----
+ In production, call this method on a 60-second timer.
+ The manager does not automatically reset counters.
+
+ """
+ # Acquire lock for thread-safe counter reset
+ with self._lock:
+ for usage in self._usage.values():
+ usage.requests_this_minute = 0
+ usage.tokens_this_minute = 0
+ usage.minute_started_at = None
+
+ def reset_day_counters(self) -> None:
+ """Reset all counters (RPM, TPM, RPD) for all models.
+
+ Resets all counters including daily requests. This should be
+ called at midnight UTC by a scheduler. Also resets minute
+ counters for consistency.
+
+ Example:
+ -------
+ >>> manager = QuotaManager()
+ >>> manager.record_usage("gemini-2.5-flash", tokens=100)
+ >>> manager.reset_day_counters()
+ >>> status = manager.get_status("gemini-2.5-flash")
+ >>> status.requests_remaining_day
+ 20
+
+ Note:
+ ----
+ In production, call this method at midnight UTC.
+ The manager does not automatically reset counters.
"""
- raise NotImplementedError("get_status() will be implemented in Step 3.1")
+ # Acquire lock for thread-safe counter reset
+ with self._lock:
+ for usage in self._usage.values():
+ # Reset all counters (minute + day)
+ usage.requests_this_minute = 0
+ usage.tokens_this_minute = 0
+ usage.requests_today = 0
+ usage.minute_started_at = None
+ usage.day_started_at = None
+ # Note: cooldown_until is NOT reset - rate limits should
+ # persist across counter resets until they expire naturally
diff --git a/src/rag_chatbot/llm/registry.py b/src/rag_chatbot/llm/registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..a618f36020e59aa50ed5f0095cccb5a266b3d74e
--- /dev/null
+++ b/src/rag_chatbot/llm/registry.py
@@ -0,0 +1,676 @@
+"""Provider Registry for managing LLM model instances with automatic fallback.
+
+This module provides the ProviderRegistry class which manages multiple Gemini LLM
+model instances and implements automatic fallback when rate limits are hit.
+The registry follows a priority-based fallback strategy to maximize availability.
+
+Components:
+ - ProviderRegistry: Main class for managing model instances and fallback
+ - AllModelsExhaustedError: Exception raised when all models are rate limited
+ - MODEL_PRIORITY: List of models in fallback priority order
+ - PROVIDER_TIMEOUT_MS: Default timeout for provider requests
+
+Model Priority Order (Free Tier):
+ 1. gemini-2.5-flash-lite: 10 RPM, 250K TPM, 20 RPD (Primary)
+ 2. gemini-2.5-flash: 5 RPM, 250K TPM, 20 RPD (Secondary)
+ 3. gemini-3-flash: 5 RPM, 250K TPM, 20 RPD (Tertiary)
+ 4. gemma-3-27b-it: 30 RPM, 15K TPM, 14.4K RPD (Final fallback)
+
+Thread Safety:
+ Model creation is protected by a threading.Lock to ensure only one
+ instance is created per model, even under concurrent access.
+
+Lazy Loading:
+ Models are created on first use, not at initialization. This minimizes
+ startup overhead and memory usage.
+
+Example:
+-------
+ >>> from rag_chatbot.llm.registry import ProviderRegistry
+ >>> from rag_chatbot.llm.base import LLMRequest
+ >>>
+ >>> # Initialize with API key
+ >>> registry = ProviderRegistry(api_key="your-api-key")
+ >>>
+ >>> # Check availability
+ >>> if registry.is_available:
+ ... request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for Predicted Mean Vote..."],
+ ... )
+ ... # Auto-fallback on rate limits
+ ... response = await registry.generate(request)
+ ... print(response.content)
+
+Note:
+----
+ Requires the google-generativeai package to be installed for GeminiLLM.
+ The QuotaManager is used to track usage and determine model availability.
+
+"""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING
+
+from .gemini import GeminiLLM, RateLimitError
+from .quota import ModelStatus, QuotaManager
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
+
+if TYPE_CHECKING:
+ from .base import LLMRequest, LLMResponse
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "ProviderRegistry",
+ "AllModelsExhaustedError",
+ "MODEL_PRIORITY",
+ "PROVIDER_TIMEOUT_MS",
+]
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Model fallback priority order (highest priority first)
+# These models are tried in order when the previous model is rate limited.
+# Priority is based on:
+# 1. RPM limits (higher is better for burst traffic)
+# 2. TPM limits (higher is better for long responses)
+# 3. Model quality (flash-lite is fastest, gemma is most capable)
+MODEL_PRIORITY: list[str] = [
+ "gemini-2.5-flash-lite", # Primary: 10 RPM, 250K TPM, 20 RPD
+ "gemini-2.5-flash", # Secondary: 5 RPM, 250K TPM, 20 RPD
+ "gemini-3-flash", # Tertiary: 5 RPM, 250K TPM, 20 RPD
+ "gemma-3-27b-it", # Final: 30 RPM, 15K TPM, 14.4K RPD
+]
+
+# Default timeout for provider requests in milliseconds
+# 30 seconds is a reasonable balance between allowing long responses
+# and not blocking too long on failed requests
+PROVIDER_TIMEOUT_MS: int = 30_000
+
+
+# =============================================================================
+# Exceptions
+# =============================================================================
+
+
+class AllModelsExhaustedError(Exception):
+ """Exception raised when all models are rate limited.
+
+ This exception is raised when all models in the fallback chain have
+ been exhausted (either rate limited or encountered errors). It provides
+ information about when the next model might become available.
+
+ Attributes:
+ ----------
+ statuses : list[ModelStatus]
+ List of ModelStatus objects for all models, showing their
+ current availability and cooldown information.
+
+ Properties:
+ ----------
+ min_retry_after : int | None
+ The minimum number of seconds until any model becomes available.
+ Returns None if no models have active cooldowns (e.g., all are
+ blocked due to quota exhaustion rather than rate limits).
+
+ Example:
+ -------
+ >>> try:
+ ... response = await registry.generate(request)
+ ... except AllModelsExhaustedError as e:
+ ... print(f"All models exhausted")
+ ... if e.min_retry_after:
+ ... print(f"Retry in {e.min_retry_after} seconds")
+ ... for status in e.statuses:
+ ... print(f" {status.model}: {status.reason}")
+
+ Note:
+ ----
+ The min_retry_after property is useful for implementing retry logic
+ with exponential backoff. If None is returned, the caller should
+ wait for quota reset (usually 1 minute for RPM/TPM).
+
+ """
+
+ def __init__(self, statuses: list[ModelStatus], message: str) -> None:
+ """Initialize AllModelsExhaustedError with status information.
+
+ Args:
+ ----
+ statuses: List of ModelStatus objects for all models.
+ message: Human-readable error message.
+
+ Example:
+ -------
+ >>> statuses = [manager.get_status(m) for m in MODEL_PRIORITY]
+ >>> raise AllModelsExhaustedError(statuses, "All models rate limited")
+
+ """
+ super().__init__(message)
+ self.statuses = statuses
+
+ @property
+ def min_retry_after(self) -> int | None:
+ """Get minimum seconds until any model becomes available.
+
+ Iterates through all model statuses and finds the minimum cooldown
+ period. This is useful for determining when to retry requests.
+
+ Returns:
+ -------
+ Minimum cooldown seconds across all models, or None if no models
+ have active cooldowns (i.e., all are blocked for other reasons
+ like quota exhaustion).
+
+ Example:
+ -------
+ >>> error = AllModelsExhaustedError(statuses, "Exhausted")
+ >>> if error.min_retry_after:
+ ... await asyncio.sleep(error.min_retry_after)
+ ... # Retry request
+
+ """
+ # Collect all non-None cooldown values
+ cooldowns = [s.cooldown_seconds for s in self.statuses if s.cooldown_seconds]
+
+ # Return minimum if any cooldowns exist, otherwise None
+ return min(cooldowns) if cooldowns else None
+
+
+# =============================================================================
+# ProviderRegistry Class
+# =============================================================================
+
+
+class ProviderRegistry:
+ """Registry for managing LLM model instances with automatic fallback.
+
+ This class manages multiple GeminiLLM instances and provides automatic
+ fallback when rate limits are hit. It tracks usage via QuotaManager
+ and creates model instances lazily on first use.
+
+ The registry follows a priority-based fallback strategy:
+ 1. Try the preferred model (if specified and available)
+ 2. If unavailable, try models in MODEL_PRIORITY order
+ 3. On rate limit (429), record the limit and try next model
+ 4. On timeout, try next model
+ 5. If all models fail, raise AllModelsExhaustedError
+
+ Attributes:
+ ----------
+ is_available : bool
+ True if any model is currently available for requests.
+
+ Methods:
+ -------
+ get_model(model: str) -> GeminiLLM
+ Get or create a model instance (lazy loading).
+
+ get_available_model(preferred: str | None = None) -> GeminiLLM | None
+ Get the first available model, optionally preferring a specific one.
+
+ generate(request: LLMRequest, preferred_model: str | None = None)
+ Generate a response with automatic fallback on rate limits.
+
+ stream(request: LLMRequest, preferred_model: str | None = None)
+ Stream a response with automatic fallback on rate limits.
+
+ get_quota_status() -> list[ModelStatus]
+ Get quota status for all models.
+
+ Thread Safety:
+ Model creation is protected by a lock. Multiple threads can safely
+ call get_model() concurrently; only one instance per model is created.
+
+ Example:
+ -------
+ >>> registry = ProviderRegistry(api_key="your-key")
+ >>>
+ >>> # Simple usage - auto-selects best available model
+ >>> response = await registry.generate(request)
+ >>>
+ >>> # Prefer a specific model
+ >>> response = await registry.generate(
+ ... request, preferred_model="gemini-3-flash"
+ ... )
+ >>>
+ >>> # Check quota status
+ >>> for status in registry.get_quota_status():
+ ... print(f"{status.model}: {status.is_available}")
+
+ Note:
+ ----
+ The registry shares a single QuotaManager across all models. Usage
+ is recorded automatically after successful requests.
+
+ """
+
+ def __init__(
+ self,
+ api_key: str | None,
+ timeout_ms: int = PROVIDER_TIMEOUT_MS,
+ ) -> None:
+ """Initialize the ProviderRegistry with API key and configuration.
+
+ Creates a new ProviderRegistry with the specified API key. Models
+ are NOT created at initialization; they are lazily loaded on first use.
+
+ Args:
+ ----
+ api_key: Gemini API key for authentication. Must be a non-empty
+ string. This key is used for all models in the registry.
+ timeout_ms: Request timeout in milliseconds. Defaults to 30000
+ (30 seconds). Applied to all model instances.
+
+ Raises:
+ ------
+ ValueError: If api_key is None, empty, or whitespace-only.
+
+ Example:
+ -------
+ >>> # Basic initialization
+ >>> registry = ProviderRegistry(api_key="your-api-key")
+ >>>
+ >>> # Custom timeout
+ >>> registry = ProviderRegistry(
+ ... api_key="your-api-key",
+ ... timeout_ms=60000, # 60 seconds
+ ... )
+
+ Note:
+ ----
+ The API key is validated for non-empty format but not for validity.
+ Actual authentication happens on first API call.
+
+ """
+ # Validate api_key is not empty
+ if api_key is None or not api_key.strip():
+ msg = "api_key cannot be empty"
+ raise ValueError(msg)
+
+ # Store configuration
+ self._api_key: str = api_key.strip()
+ self._timeout_ms: int = timeout_ms
+
+ # Initialize model cache (lazy loading)
+ # Models are created on first use via get_model()
+ self._models: dict[str, GeminiLLM] = {}
+
+ # Lock for thread-safe model creation
+ # Ensures only one instance per model even under concurrent access
+ self._lock: threading.Lock = threading.Lock()
+
+ # Initialize quota manager for tracking usage across all models
+ # The same manager is shared by the registry for all operations
+ self._quota_manager: QuotaManager = QuotaManager()
+
+ def get_model(self, model: str) -> GeminiLLM:
+ """Get or create a GeminiLLM instance for the specified model.
+
+ This method implements lazy loading of model instances. The first
+ call for a model creates the instance; subsequent calls return the
+ cached instance. Thread-safe: only one instance is created per model.
+
+ Args:
+ ----
+ model: Model name (e.g., "gemini-2.5-flash-lite").
+ Must be one of the models in MODEL_PRIORITY.
+
+ Returns:
+ -------
+ GeminiLLM instance configured for the specified model.
+
+ Raises:
+ ------
+ KeyError: If the model is not in MODEL_PRIORITY.
+
+ Example:
+ -------
+ >>> registry = ProviderRegistry(api_key="key")
+ >>> model = registry.get_model("gemini-2.5-flash-lite")
+ >>> model.model_name
+ 'gemini-2.5-flash-lite'
+
+ Note:
+ ----
+ The returned instance uses the registry's API key and timeout.
+ Model instances are cached for the lifetime of the registry.
+
+ """
+ # Validate model name
+ if model not in MODEL_PRIORITY:
+ msg = f"Unknown model: {model!r}. Supported models: {MODEL_PRIORITY}"
+ raise KeyError(msg)
+
+ # Fast path: check if model already exists (no lock needed for read)
+ if model in self._models:
+ return self._models[model]
+
+ # Slow path: create model with lock (thread-safe)
+ with self._lock:
+ # Double-check pattern: another thread may have created it
+ if model in self._models:
+ return self._models[model]
+
+ # Create new model instance
+ llm = GeminiLLM(
+ api_key=self._api_key,
+ model=model,
+ timeout_ms=self._timeout_ms,
+ )
+
+ # Cache the instance
+ self._models[model] = llm
+
+ return llm
+
+ def get_available_model(self, preferred: str | None = None) -> GeminiLLM | None:
+ """Get the first available model, optionally preferring a specific one.
+
+ Checks model availability using the QuotaManager and returns the
+ first available model. If a preferred model is specified and available,
+ it is returned; otherwise, models are checked in MODEL_PRIORITY order.
+
+ Args:
+ ----
+ preferred: Optional preferred model name. If specified and the
+ model is available, it will be returned. If unavailable,
+ fallback to MODEL_PRIORITY order.
+
+ Returns:
+ -------
+ GeminiLLM instance for the first available model, or None if
+ all models are exhausted.
+
+ Example:
+ -------
+ >>> # Get best available model
+ >>> model = registry.get_available_model()
+ >>>
+ >>> # Prefer a specific model
+ >>> model = registry.get_available_model(preferred="gemini-3-flash")
+ >>> if model:
+ ... print(f"Using {model.model_name}")
+ ... else:
+ ... print("No models available")
+
+ Note:
+ ----
+ Availability is determined by the QuotaManager, which tracks
+ RPM, TPM, RPD, and cooldown status for each model.
+
+ """
+ # Try preferred model first if specified and available
+ if (
+ preferred is not None
+ and preferred in MODEL_PRIORITY
+ and self._quota_manager.is_available(preferred)
+ ):
+ return self.get_model(preferred)
+
+ # Fall back to priority order
+ for model_name in MODEL_PRIORITY:
+ if self._quota_manager.is_available(model_name):
+ return self.get_model(model_name)
+
+ # No models available
+ return None
+
+ async def generate(
+ self,
+ request: LLMRequest,
+ preferred_model: str | None = None,
+ ) -> LLMResponse:
+ """Generate a response with automatic fallback on rate limits.
+
+ Attempts to generate a response using the preferred model (if specified)
+ or the first available model. If a rate limit (429) or timeout occurs,
+ automatically falls back to the next available model in priority order.
+
+ Args:
+ ----
+ request: The LLMRequest containing query, context, and parameters.
+ preferred_model: Optional preferred model name. If specified and
+ available, it will be tried first.
+
+ Returns:
+ -------
+ LLMResponse with generated content and metadata.
+
+ Raises:
+ ------
+ AllModelsExhaustedError: If all models are rate limited or failed.
+ TypeError: If request is not an LLMRequest instance.
+
+ Example:
+ -------
+ >>> request = LLMRequest(
+ ... query="What is PMV?",
+ ... context=["PMV stands for..."],
+ ... )
+ >>> try:
+ ... response = await registry.generate(request)
+ ... print(response.content)
+ ... except AllModelsExhaustedError as e:
+ ... print(f"Retry in {e.min_retry_after} seconds")
+
+ Note:
+ ----
+ Usage is recorded automatically after successful requests.
+ Rate limits are recorded to update cooldown periods.
+
+ """
+ # Import here to avoid circular imports
+ from .base import LLMRequest as LLMRequestClass
+
+ # Validate input type
+ if not isinstance(request, LLMRequestClass):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Build list of models to try
+ # Start with preferred model if specified and available
+ models_to_try: list[str] = []
+
+ if (
+ preferred_model is not None
+ and preferred_model in MODEL_PRIORITY
+ and self._quota_manager.is_available(preferred_model)
+ ):
+ models_to_try.append(preferred_model)
+
+ # Add remaining models in priority order (excluding already added)
+ for model_name in MODEL_PRIORITY:
+ if model_name not in models_to_try and self._quota_manager.is_available(
+ model_name
+ ):
+ models_to_try.append(model_name)
+
+ # Try each model until one succeeds
+ for model_name in models_to_try:
+ try:
+ # Get the model instance
+ llm = self.get_model(model_name)
+
+ # Attempt to generate response
+ response = await llm.generate(request)
+
+ except RateLimitError as e:
+ # Record rate limit and continue to next model
+ self._quota_manager.record_rate_limit(model_name, e.retry_after)
+ continue
+
+ except TimeoutError:
+ # Timeout - try next model (no cooldown recorded)
+ continue
+
+ else:
+ # Record successful usage and return response
+ self._quota_manager.record_usage(
+ model_name, tokens=response.tokens_used
+ )
+ return response
+
+ # All models exhausted - raise with status information
+ statuses = self._quota_manager.get_all_status()
+ msg = "All models are rate limited or unavailable"
+ raise AllModelsExhaustedError(statuses, msg)
+
+ async def stream(
+ self,
+ request: LLMRequest,
+ preferred_model: str | None = None,
+ ) -> AsyncIterator[str]:
+ """Stream a response with automatic fallback on rate limits.
+
+ Attempts to stream a response using the preferred model (if specified)
+ or the first available model. If a rate limit (429) or timeout occurs
+ before streaming starts, automatically falls back to the next available
+ model in priority order.
+
+ Args:
+ ----
+ request: The LLMRequest containing query, context, and parameters.
+ preferred_model: Optional preferred model name. If specified and
+ available, it will be tried first.
+
+ Yields:
+ ------
+ String chunks of the generated response as they become available.
+
+ Raises:
+ ------
+ AllModelsExhaustedError: If all models are rate limited or failed.
+ TypeError: If request is not an LLMRequest instance.
+
+ Example:
+ -------
+ >>> request = LLMRequest(query="What is PMV?", context=[...])
+ >>> try:
+ ... async for chunk in registry.stream(request):
+ ... print(chunk, end="", flush=True)
+ ... except AllModelsExhaustedError as e:
+ ... print(f"Retry in {e.min_retry_after} seconds")
+
+ Note:
+ ----
+ Rate limits encountered before streaming starts trigger fallback.
+ Rate limits during streaming may not trigger fallback (depends on
+ when the error occurs in the stream).
+
+ """
+ # Import here to avoid circular imports
+ from .base import LLMRequest as LLMRequestClass
+
+ # Validate input type
+ if not isinstance(request, LLMRequestClass):
+ msg = f"Expected LLMRequest, got {type(request).__name__}"
+ raise TypeError(msg)
+
+ # Build list of models to try
+ # Start with preferred model if specified and available
+ models_to_try: list[str] = []
+
+ if (
+ preferred_model is not None
+ and preferred_model in MODEL_PRIORITY
+ and self._quota_manager.is_available(preferred_model)
+ ):
+ models_to_try.append(preferred_model)
+
+ # Add remaining models in priority order (excluding already added)
+ for model_name in MODEL_PRIORITY:
+ if model_name not in models_to_try and self._quota_manager.is_available(
+ model_name
+ ):
+ models_to_try.append(model_name)
+
+ # Try each model until one succeeds
+ for model_name in models_to_try:
+ try:
+ # Get the model instance
+ llm = self.get_model(model_name)
+
+ # Attempt to stream response
+ # We need to iterate through the stream to check for errors
+ async for chunk in llm.stream(request):
+ yield chunk
+
+ except RateLimitError as e:
+ # Record rate limit and continue to next model
+ self._quota_manager.record_rate_limit(model_name, e.retry_after)
+ continue
+
+ except TimeoutError:
+ # Timeout - try next model (no cooldown recorded)
+ continue
+
+ else:
+ # Stream completed successfully, return
+ return
+
+ # All models exhausted - raise with status information
+ statuses = self._quota_manager.get_all_status()
+ msg = "All models are rate limited or unavailable"
+ raise AllModelsExhaustedError(statuses, msg)
+
+ def get_quota_status(self) -> list[ModelStatus]:
+ """Get quota status for all models.
+
+ Returns the current availability and quota information for all
+ models in MODEL_PRIORITY. Useful for monitoring and debugging.
+
+ Returns:
+ -------
+ List of ModelStatus objects, one for each model in MODEL_PRIORITY.
+
+ Example:
+ -------
+ >>> for status in registry.get_quota_status():
+ ... print(f"{status.model}:")
+ ... print(f" Available: {status.is_available}")
+ ... print(f" RPM remaining: {status.requests_remaining_minute}")
+ ... if status.cooldown_seconds:
+ ... print(f" Cooldown: {status.cooldown_seconds}s")
+
+ """
+ return self._quota_manager.get_all_status()
+
+ @property
+ def is_available(self) -> bool:
+ """Check if any model is available for requests.
+
+ Returns True if at least one model in MODEL_PRIORITY is available
+ according to the QuotaManager. This is a quick check that does not
+ make network calls.
+
+ Returns:
+ -------
+ True if any model is available, False if all are exhausted.
+
+ Example:
+ -------
+ >>> if registry.is_available:
+ ... response = await registry.generate(request)
+ ... else:
+ ... # All models exhausted, wait or return error
+ ... ...
+
+ """
+ return any(
+ self._quota_manager.is_available(model_name)
+ for model_name in MODEL_PRIORITY
+ )
diff --git a/src/rag_chatbot/qlog/__init__.py b/src/rag_chatbot/qlog/__init__.py
index 059bdabc1defefd18454013f487521252226c460..a3f6bd367b7232699a084b33ca70ad4e248ee72f 100644
--- a/src/rag_chatbot/qlog/__init__.py
+++ b/src/rag_chatbot/qlog/__init__.py
@@ -10,11 +10,13 @@ for analytics and improvement. The logging captures:
Components:
- QueryLog: Pydantic model for log entries
- HFDatasetWriter: Async writer for HuggingFace datasets
+ - QueryLogService: Singleton service for lifecycle management
+ - get_query_log_service: Factory function for the singleton
+ - on_startup/on_shutdown: FastAPI lifespan integration hooks
Lazy Loading:
- HuggingFace datasets library is loaded on first access using
- __getattr__. This ensures fast import times when logging is
- not immediately needed.
+ All components are loaded on first access using __getattr__.
+ This ensures fast import times when logging is not immediately needed.
Privacy:
The logging system is designed to NOT capture any PII:
@@ -22,11 +24,32 @@ Privacy:
- No user identifiers
- No session tracking
-Example:
+Example (using service):
-------
- >>> from rag_chatbot.qlog import QueryLog, HFDatasetWriter
- >>> writer = HFDatasetWriter(repo_id="user/qlog")
- >>> await writer.log(QueryLog(query="What is PMV?", ...))
+ >>> from rag_chatbot.qlog import get_query_log_service
+ >>> service = get_query_log_service()
+ >>> await service.start()
+ >>> await service.log_query(
+ ... question="What is PMV?",
+ ... answer="PMV is...",
+ ... sources=["doc.pdf:p1"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... )
+ >>> await service.stop()
+
+Example (FastAPI integration):
+-------
+ >>> from contextlib import asynccontextmanager
+ >>> from fastapi import FastAPI
+ >>> from rag_chatbot.qlog import on_startup, on_shutdown
+ >>>
+ >>> @asynccontextmanager
+ ... async def lifespan(app: FastAPI):
+ ... await on_startup()
+ ... yield
+ ... await on_shutdown()
+ >>>
+ >>> app = FastAPI(lifespan=lifespan)
"""
@@ -37,18 +60,26 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .hf_writer import HFDatasetWriter
from .models import QueryLog
+ from .service import QueryLogService
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["QueryLog", "HFDatasetWriter"]
+__all__: list[str] = [
+ "QueryLog",
+ "HFDatasetWriter",
+ "QueryLogService",
+ "get_query_log_service",
+ "on_startup",
+ "on_shutdown",
+]
def __getattr__(name: str) -> object:
"""Lazy load module exports on first access.
This function is called when an attribute is not found in the module's
- namespace. It enables lazy loading of HuggingFace datasets library.
+ namespace. It enables lazy loading of dependencies (pydantic, huggingface_hub).
Args:
----
@@ -71,5 +102,21 @@ def __getattr__(name: str) -> object:
from .hf_writer import HFDatasetWriter
return HFDatasetWriter
+ if name == "QueryLogService":
+ from .service import QueryLogService
+
+ return QueryLogService
+ if name == "get_query_log_service":
+ from .service import get_query_log_service
+
+ return get_query_log_service
+ if name == "on_startup":
+ from .service import on_startup
+
+ return on_startup
+ if name == "on_shutdown":
+ from .service import on_shutdown
+
+ return on_shutdown
msg = f"module {__name__!r} has no attribute {name!r}" # pragma: no cover
raise AttributeError(msg) # pragma: no cover
diff --git a/src/rag_chatbot/qlog/hf_writer.py b/src/rag_chatbot/qlog/hf_writer.py
index 8aab71e9a3ccb258eea0c61253e62d977fb356f5..d5485dc3a39f47a0850fb419f66ca1c9b7b39588 100644
--- a/src/rag_chatbot/qlog/hf_writer.py
+++ b/src/rag_chatbot/qlog/hf_writer.py
@@ -2,25 +2,75 @@
This module provides the HFDatasetWriter class for asynchronously
writing query logs to a HuggingFace dataset. The writer:
- - Batches writes for efficiency
- - Handles authentication
- - Provides retry logic
- - Supports background flushing
+ - Batches writes for efficiency (reduces HF API calls)
+ - Handles authentication via HF_TOKEN environment variable
+ - Provides retry logic for failed uploads
+ - Supports background flushing on interval or batch size threshold
+
+Design Philosophy:
+ The writer is designed to be NON-BLOCKING. When log() is called, it
+ simply adds the entry to an internal buffer and returns immediately.
+ This ensures that query logging never slows down the response to users.
+
+ Batching is used to reduce the number of HF API calls. Instead of
+ uploading each log entry individually, entries are accumulated in a
+ buffer and uploaded together when:
+ 1. The buffer reaches batch_size (default: 10 entries), OR
+ 2. The flush interval expires (default: 300 seconds / 5 minutes)
+
+ If an upload fails, the logs are stored locally in a `failed_logs/`
+ directory and will be retried on the next successful flush (up to
+ 3 retry attempts per entry).
+
+Thread-Safety:
+ The writer uses asyncio.Lock to ensure thread-safe access to the
+ internal buffer. This allows multiple async tasks to call log()
+ concurrently without data corruption.
Lazy Loading:
huggingface_hub is loaded on first use to avoid import overhead.
-
-Note:
-----
- This is a placeholder that will be fully implemented in Step 4.3.
+ This follows the project convention used throughout the codebase.
+
+Example:
+-------
+ >>> from rag_chatbot.qlog import QueryLog, HFDatasetWriter
+ >>> from rag_chatbot.config import Settings
+ >>> from datetime import datetime, UTC
+ >>>
+ >>> settings = Settings()
+ >>> writer = HFDatasetWriter(
+ ... repo_id=settings.hf_qlog_repo,
+ ... batch_size=settings.qlog_batch_size,
+ ... flush_interval=settings.qlog_flush_interval_seconds,
+ ... hf_token=settings.hf_token,
+ ... )
+ >>>
+ >>> log_entry = QueryLog(
+ ... timestamp=datetime.now(UTC),
+ ... question="What is PMV?",
+ ... answer="PMV (Predicted Mean Vote) is...",
+ ... sources=["ASHRAE_55.pdf:p12"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... )
+ >>> await writer.log(log_entry) # Non-blocking, adds to buffer
+ >>> await writer.close() # Flush all pending logs on shutdown
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
+import asyncio
+import contextlib
+import json
+import logging
+import os
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
+ from huggingface_hub import HfApi # type: ignore[attr-defined]
+
from .models import QueryLog
# =============================================================================
@@ -28,8 +78,108 @@ if TYPE_CHECKING:
# =============================================================================
__all__: list[str] = ["HFDatasetWriter"]
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Default directory for storing failed logs locally
+FAILED_LOGS_DIR: str = "failed_logs"
+
+# Filename in HF repository for query logs
+QLOG_FILENAME: str = "qlog.jsonl"
+
+# Maximum number of retry attempts for failed log entries
+MAX_RETRY_ATTEMPTS: int = 3
+
+
+# =============================================================================
+# Internal Data Structures
+# =============================================================================
+
+
+class _RetryEntry:
+ """Internal class to track log entries with retry metadata.
+
+ This class wraps a QueryLog entry with additional metadata for
+ tracking retry attempts when HF uploads fail.
+
+ Attributes
+ ----------
+ data : dict[str, Any]
+ The serialized QueryLog data (from to_dict()).
+ retry_count : int
+ Number of failed upload attempts.
+ created_at : str
+ ISO 8601 timestamp when the entry was first logged.
+
+ """
+
+ __slots__ = ("data", "retry_count", "created_at")
+
+ def __init__(
+ self,
+ data: dict[str, Any],
+ retry_count: int = 0,
+ created_at: str | None = None,
+ ) -> None:
+ """Initialize a retry entry.
+
+ Args:
+ ----
+ data: Serialized QueryLog data dictionary.
+ retry_count: Number of previous retry attempts. Defaults to 0.
+ created_at: ISO 8601 timestamp. Defaults to current time.
+
+ """
+ self.data = data
+ self.retry_count = retry_count
+ self.created_at = created_at or datetime.now(UTC).isoformat()
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize the retry entry for local storage.
+
+ Returns
+ -------
+ Dictionary with data, retry_count, and created_at fields.
+
+ """
+ return {
+ "data": self.data,
+ "retry_count": self.retry_count,
+ "created_at": self.created_at,
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict[str, Any]) -> _RetryEntry:
+ """Deserialize a retry entry from local storage.
+
+ Args:
+ ----
+ d: Dictionary with data, retry_count, and created_at fields.
+
+ Returns:
+ -------
+ A new _RetryEntry instance.
+
+ """
+ return cls(
+ data=d["data"],
+ retry_count=d.get("retry_count", 0),
+ created_at=d.get("created_at"),
+ )
-class HFDatasetWriter: # pragma: no cover
+
+# =============================================================================
+# HFDatasetWriter Class
+# =============================================================================
+
+
+class HFDatasetWriter:
"""Async writer for HuggingFace dataset logging.
This class provides methods for asynchronously writing query
@@ -40,90 +190,581 @@ class HFDatasetWriter: # pragma: no cover
or when the buffer reaches a threshold. This prevents excessive
API calls while ensuring logs are persisted.
+ Design Principles:
+ 1. **Non-Blocking**: The log() method never blocks. It adds entries
+ to a buffer and returns immediately. This ensures logging never
+ impacts response latency.
+
+ 2. **Batching**: Instead of uploading each log individually, entries
+ are batched together to reduce HF API calls. This improves
+ efficiency and reduces the chance of hitting rate limits.
+
+ 3. **Resilient**: If HF upload fails, logs are stored locally and
+ retried on subsequent flushes. This ensures no data is lost
+ due to transient network issues.
+
+ 4. **Thread-Safe**: Uses asyncio.Lock for concurrent access.
+
Attributes:
----------
- repo_id: HuggingFace repository ID for the dataset.
- batch_size: Number of logs to batch before writing.
- flush_interval: Seconds between automatic flushes.
+ repo_id : str
+ HuggingFace repository ID for the dataset.
+ batch_size : int
+ Number of logs to batch before writing.
+ flush_interval : float
+ Seconds between automatic flushes.
Example:
-------
- >>> writer = HFDatasetWriter(repo_id="user/qlog")
+ >>> writer = HFDatasetWriter(
+ ... repo_id="sadickam/Pytherm_Qlog",
+ ... batch_size=10,
+ ... flush_interval=300.0,
+ ... hf_token="hf_xxxxx",
+ ... )
>>> await writer.log(query_log)
>>> await writer.flush() # Force write pending logs
-
- Note:
- ----
- This class will be fully implemented in Phase 4 (Step 4.3).
+ >>> await writer.close() # Flush and cleanup on shutdown
"""
- def __init__(
+ def __init__( # noqa: PLR0913
self,
repo_id: str,
batch_size: int = 10,
- flush_interval: float = 60.0,
+ flush_interval: float = 300.0,
+ hf_token: str | None = None,
+ failed_logs_dir: str | None = None,
) -> None:
"""Initialize the HuggingFace dataset writer.
Args:
----
- repo_id: HuggingFace repository ID (e.g., 'user/dataset').
+ repo_id: HuggingFace repository ID (e.g., 'sadickam/Pytherm_Qlog').
+ This repository should already exist on HuggingFace.
batch_size: Number of logs to batch before writing.
- Defaults to 10.
+ Defaults to 10. Higher values reduce API calls but
+ increase risk of data loss on crash.
flush_interval: Seconds between automatic flushes.
- Defaults to 60.0.
-
- Raises:
- ------
- NotImplementedError: HFDatasetWriter will be implemented in Step 4.3.
+ Defaults to 300.0 (5 minutes). Logs are flushed when
+ batch is full OR interval expires, whichever comes first.
+ hf_token: HuggingFace authentication token. If not provided,
+ attempts to load from HF_TOKEN environment variable.
+ failed_logs_dir: Directory for storing failed logs locally.
+ Defaults to 'failed_logs' in the current working directory.
+
+ Note:
+ ----
+ The writer does NOT start the background flush timer automatically.
+ Call start_background_flush() if you want automatic periodic flushing.
"""
self._repo_id = repo_id
self._batch_size = batch_size
self._flush_interval = flush_interval
- raise NotImplementedError("HFDatasetWriter will be implemented in Step 4.3")
+ self._hf_token = hf_token or os.environ.get("HF_TOKEN")
+ self._failed_logs_dir = Path(failed_logs_dir or FAILED_LOGS_DIR)
+
+ # Internal state
+ # Buffer holds _RetryEntry objects for pending logs
+ self._buffer: list[_RetryEntry] = []
+
+ # asyncio.Lock for thread-safe buffer access
+ # This ensures concurrent log() calls don't corrupt the buffer
+ self._lock: asyncio.Lock = asyncio.Lock()
+
+ # Background flush timer task (optional)
+ self._flush_timer_task: asyncio.Task[None] | None = None
+
+ # Background flush tasks (stored to prevent garbage collection)
+ self._background_tasks: set[asyncio.Task[None]] = set()
+
+ # Flag to track if writer is closed
+ self._closed: bool = False
+
+ # Lazy-loaded HfApi instance
+ self._hf_api: HfApi | None = None
+
+ logger.debug(
+ "HFDatasetWriter initialized: repo=%s, batch_size=%d, interval=%.0fs",
+ self._repo_id,
+ self._batch_size,
+ self._flush_interval,
+ )
+
+ @property
+ def repo_id(self) -> str:
+ """Return the HuggingFace repository ID.
+
+ Returns
+ -------
+ The repository ID string.
+
+ """
+ return self._repo_id
+
+ @property
+ def batch_size(self) -> int:
+ """Return the batch size setting.
+
+ Returns
+ -------
+ Number of logs to batch before writing.
+
+ """
+ return self._batch_size
+
+ @property
+ def flush_interval(self) -> float:
+ """Return the flush interval setting.
+
+ Returns
+ -------
+ Seconds between automatic flushes.
+
+ """
+ return self._flush_interval
+
+ @property
+ def pending_count(self) -> int:
+ """Return the number of pending logs in the buffer.
+
+ This property is safe to call without holding the lock for
+ approximate counts. For exact counts during critical operations,
+ acquire the lock first.
+
+ Returns
+ -------
+ Number of log entries waiting to be flushed.
+
+ """
+ return len(self._buffer)
+
+ def _get_hf_api(self) -> HfApi:
+ """Return the HfApi instance (lazy loading).
+
+ This method lazily imports huggingface_hub and creates an HfApi
+ instance on first use. This avoids import overhead at module
+ load time.
+
+ Returns
+ -------
+ HfApi instance configured with the HF token.
+
+ Raises
+ ------
+ ImportError: If huggingface_hub is not installed.
+
+ """
+ if self._hf_api is None:
+ # Lazy import huggingface_hub
+ from huggingface_hub import HfApi as _HfApi # type: ignore[attr-defined]
+
+ self._hf_api = _HfApi(token=self._hf_token)
+ logger.debug("HfApi instance created for repo: %s", self._repo_id)
+
+ return self._hf_api
async def log(self, entry: QueryLog) -> None:
- """Log a query entry.
+ """Log a query entry (non-blocking).
+
+ Add the entry to the internal buffer. The entry will be
+ written to the HuggingFace dataset when the buffer is flushed.
+
+ This method is NON-BLOCKING. It adds the entry to the buffer
+ and returns immediately. The actual upload happens during
+ flush() - either when batch_size is reached, the flush interval
+ expires, or close() is called.
- Adds the entry to the internal buffer. The entry will be
- written to the dataset when the buffer is flushed.
+ This design ensures that query logging never impacts the
+ response latency experienced by users.
Args:
----
- entry: QueryLog entry to write.
+ entry: QueryLog entry to write. Must have a to_dict() method
+ that returns a JSON-serializable dictionary.
- Raises:
- ------
- NotImplementedError: Method will be implemented in Step 4.3.
+ Note:
+ ----
+ If the buffer is already at batch_size after adding the entry,
+ a flush is triggered automatically in the background (without
+ blocking this method).
+
+ Example:
+ -------
+ >>> log_entry = QueryLog(
+ ... timestamp=datetime.now(UTC),
+ ... question="What is PMV?",
+ ... answer="PMV is...",
+ ... sources=["doc.pdf:p1"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... )
+ >>> await writer.log(log_entry) # Returns immediately
"""
- raise NotImplementedError("log() will be implemented in Step 4.3")
+ if self._closed:
+ logger.warning("Attempted to log after writer was closed")
+ return
+
+ # Serialize the entry to a dictionary
+ # This is done outside the lock to minimize lock hold time
+ try:
+ # QueryLog has a to_dict() method that returns a JSON-serializable dict
+ data: dict[str, Any] = entry.to_dict() # type: ignore[attr-defined]
+ except Exception:
+ logger.exception("Failed to serialize log entry")
+ return
+
+ # Create a retry entry wrapper
+ retry_entry = _RetryEntry(data=data)
+
+ # Thread-safe buffer access using asyncio.Lock
+ async with self._lock:
+ self._buffer.append(retry_entry)
+ buffer_size = len(self._buffer)
+
+ logger.debug(
+ "Log entry added to buffer (size: %d/%d)",
+ buffer_size,
+ self._batch_size,
+ )
+
+ # Check if we should flush (batch is full)
+ # We trigger flush in background without blocking
+ if buffer_size >= self._batch_size:
+ logger.debug("Buffer full, scheduling background flush")
+ task = asyncio.create_task(self._safe_flush())
+ # Store reference to prevent garbage collection
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+
+ async def _safe_flush(self) -> None:
+ """Catch all exceptions from flush() for background operations.
+
+ This method is used for background flush operations where
+ we don't want exceptions to propagate and crash the application.
+
+ """
+ try:
+ await self.flush()
+ except Exception:
+ logger.exception("Background flush failed")
async def flush(self) -> None:
- """Flush pending logs to the dataset.
+ """Flush pending logs to the HuggingFace dataset.
- Writes all buffered logs to the HuggingFace dataset.
+ Write all buffered logs to the HuggingFace dataset.
This is called automatically when the buffer reaches
batch_size or after flush_interval seconds.
- Raises
- ------
- NotImplementedError: Method will be implemented in Step 4.3.
+ The flush operation:
+ 1. Loads any previously failed logs from local storage
+ 2. Combines them with the current buffer
+ 3. Uploads to HuggingFace as JSONL
+ 4. On success, clears the buffer and removes retry files
+ 5. On failure, stores logs locally for later retry
+
+ Note:
+ ----
+ This method is thread-safe and can be called concurrently.
+ Only one flush operation will proceed at a time due to the
+ asyncio.Lock.
+
+ """
+ # Thread-safe: acquire lock for the entire flush operation
+ async with self._lock:
+ # Load any previously failed logs for retry
+ failed_entries = self._load_failed_logs()
+
+ # Combine failed logs with current buffer
+ all_entries = failed_entries + self._buffer
+
+ if not all_entries:
+ logger.debug("No logs to flush")
+ return
+
+ logger.info("Flushing %d log entries to HuggingFace", len(all_entries))
+
+ # Attempt to upload to HuggingFace
+ success = await self._upload_to_hf(all_entries)
+
+ if success:
+ # Clear the buffer
+ self._buffer.clear()
+
+ # Remove failed logs files (they've been successfully uploaded)
+ self._clear_failed_logs()
+
+ logger.info("Successfully flushed %d log entries", len(all_entries))
+ else:
+ # Store all entries locally for retry
+ # Increment retry count for entries that failed
+ entries_to_store: list[_RetryEntry] = []
+ for entry in all_entries:
+ entry.retry_count += 1
+ if entry.retry_count <= MAX_RETRY_ATTEMPTS:
+ entries_to_store.append(entry)
+ else:
+ logger.warning(
+ "Log entry exceeded max retries (%d), discarding",
+ MAX_RETRY_ATTEMPTS,
+ )
+
+ self._store_failed_logs(entries_to_store)
+
+ # Clear the buffer since entries are now in failed logs
+ self._buffer.clear()
+
+ logger.warning(
+ "Flush failed, stored %d entries locally for retry",
+ len(entries_to_store),
+ )
+
+ async def _upload_to_hf(self, entries: list[_RetryEntry]) -> bool:
+ """Upload log entries to HuggingFace.
+
+ Append log entries to the qlog.jsonl file in the HuggingFace
+ repository. Each entry is written as a single JSON line.
+
+ Args:
+ ----
+ entries: List of _RetryEntry objects to upload.
+
+ Returns:
+ -------
+ True if upload succeeded, False otherwise.
+
+ """
+ if not self._hf_token:
+ logger.warning("No HF_TOKEN configured, cannot upload logs")
+ return False
+
+ try:
+ # Lazy import huggingface_hub
+ from huggingface_hub import ( # type: ignore[attr-defined]
+ HfFileSystem as _HfFileSystem,
+ )
+
+ # Build JSONL content from entries
+ jsonl_lines: list[str] = []
+ for entry in entries:
+ json_line = json.dumps(entry.data, ensure_ascii=False)
+ jsonl_lines.append(json_line)
+
+ jsonl_content = "\n".join(jsonl_lines) + "\n"
+
+ # Use HfFileSystem for append operation
+ # This is more efficient than downloading, appending, and re-uploading
+ fs = _HfFileSystem(token=self._hf_token)
+ file_path = f"datasets/{self._repo_id}/{QLOG_FILENAME}"
+
+ # Append to the file (creates if doesn't exist)
+ # Note: HfFileSystem.open() with mode 'a' appends to existing content
+ try:
+ with fs.open(file_path, mode="a") as f:
+ f.write(jsonl_content)
+ except FileNotFoundError:
+ # File doesn't exist, create it with write mode
+ with fs.open(file_path, mode="w") as f:
+ f.write(jsonl_content)
+
+ logger.debug("Uploaded %d entries to %s", len(entries), file_path)
+
+ except ImportError:
+ logger.exception("huggingface_hub not installed, cannot upload logs")
+ return False
+ except Exception:
+ logger.exception("Failed to upload logs to HuggingFace")
+ return False
+ else:
+ return True
+
+ def _load_failed_logs(self) -> list[_RetryEntry]:
+ """Load previously failed logs from local storage.
+
+ Read retry entries from the failed_logs directory. These are
+ logs that failed to upload on previous attempts and are being
+ retried.
+
+ Returns
+ -------
+ List of _RetryEntry objects loaded from local storage.
"""
- raise NotImplementedError("flush() will be implemented in Step 4.3")
+ entries: list[_RetryEntry] = []
+
+ if not self._failed_logs_dir.exists():
+ return entries
+
+ # Load all .json files from the failed logs directory
+ for json_file in self._failed_logs_dir.glob("*.json"):
+ try:
+ with open(json_file, encoding="utf-8") as f:
+ data = json.load(f)
+
+ # Handle both single entry and list of entries
+ if isinstance(data, list):
+ for item in data:
+ entries.append(_RetryEntry.from_dict(item))
+ else:
+ entries.append(_RetryEntry.from_dict(data))
+
+ logger.debug("Loaded %d retry entries from %s", len(entries), json_file)
+
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.warning("Failed to load failed log file %s: %s", json_file, e)
+ continue
+
+ return entries
+
+ def _store_failed_logs(self, entries: list[_RetryEntry]) -> None:
+ """Store failed logs locally for later retry.
+
+ Save log entries to the failed_logs directory as JSON files.
+ These will be retried on the next flush operation.
+
+ Args:
+ ----
+ entries: List of _RetryEntry objects to store locally.
+
+ """
+ if not entries:
+ return
+
+ # Ensure the failed logs directory exists
+ self._failed_logs_dir.mkdir(parents=True, exist_ok=True)
+
+ # Generate a unique filename based on timestamp
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
+ filename = f"failed_logs_{timestamp}.json"
+ filepath = self._failed_logs_dir / filename
+
+ # Serialize entries to JSON
+ data = [entry.to_dict() for entry in entries]
+
+ try:
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+ logger.debug("Stored %d failed entries to %s", len(entries), filepath)
+
+ except OSError:
+ logger.exception("Failed to store failed logs locally")
+
+ def _clear_failed_logs(self) -> None:
+ """Remove all failed log files from local storage.
+
+ Call after successful upload to clean up retry files.
+
+ """
+ if not self._failed_logs_dir.exists():
+ return
+
+ for json_file in self._failed_logs_dir.glob("*.json"):
+ try:
+ json_file.unlink()
+ logger.debug("Removed failed log file: %s", json_file)
+ except OSError as e:
+ logger.warning("Failed to remove failed log file %s: %s", json_file, e)
+
+ def start_background_flush(self) -> None:
+ """Start the background flush timer.
+
+ Start an asyncio task that periodically flushes the buffer
+ at the configured flush_interval. This ensures logs are
+ persisted even if the batch_size is never reached.
+
+ The timer is automatically cancelled when close() is called.
+
+ Note:
+ ----
+ This method should only be called once. Calling it multiple
+ times will cancel the previous timer and start a new one.
+
+ Example:
+ -------
+ >>> writer = HFDatasetWriter(repo_id="user/repo")
+ >>> writer.start_background_flush()
+ >>> # Background flush will occur every flush_interval seconds
+ >>> await writer.close() # Cancels timer and flushes remaining
+
+ """
+ # Cancel existing timer if any
+ if self._flush_timer_task is not None and not self._flush_timer_task.done():
+ self._flush_timer_task.cancel()
+
+ # Start new background timer task
+ self._flush_timer_task = asyncio.create_task(self._background_flush_loop())
+ logger.debug(
+ "Started background flush timer (interval: %.0fs)",
+ self._flush_interval,
+ )
+
+ async def _background_flush_loop(self) -> None:
+ """Run background loop that periodically flushes the buffer.
+
+ This coroutine runs indefinitely, sleeping for flush_interval
+ seconds between each flush. It catches all exceptions to prevent
+ the loop from dying due to transient errors.
+
+ The loop exits when the writer is closed or the task is cancelled.
+
+ """
+ while not self._closed:
+ try:
+ # Sleep for the flush interval
+ await asyncio.sleep(self._flush_interval)
+
+ # Perform flush if writer is still open
+ if not self._closed and self.pending_count > 0:
+ logger.debug("Background flush triggered (interval expired)")
+ await self._safe_flush()
+
+ except asyncio.CancelledError:
+ # Task was cancelled (e.g., during close())
+ logger.debug("Background flush loop cancelled")
+ break
+ except Exception:
+ # Log error but continue the loop
+ logger.exception("Error in background flush loop")
async def close(self) -> None:
"""Close the writer and flush remaining logs.
- Should be called when shutting down the application to
- ensure all pending logs are written.
+ Flush all pending logs and cancel the background flush timer.
+ Should be called when shutting down the application to ensure
+ all pending logs are written.
- Raises
- ------
- NotImplementedError: Method will be implemented in Step 4.3.
+ After calling close(), the writer cannot be used again. Any
+ subsequent calls to log() will be ignored with a warning.
+
+ Example:
+ -------
+ >>> writer = HFDatasetWriter(repo_id="user/repo")
+ >>> # ... use writer ...
+ >>> await writer.close() # Flush remaining logs
"""
- raise NotImplementedError("close() will be implemented in Step 4.3")
+ if self._closed:
+ logger.warning("Writer already closed")
+ return
+
+ logger.info("Closing HFDatasetWriter, flushing remaining logs...")
+
+ # Mark as closed to prevent new logs
+ self._closed = True
+
+ # Cancel background flush timer if running
+ if self._flush_timer_task is not None and not self._flush_timer_task.done():
+ self._flush_timer_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._flush_timer_task
+ logger.debug("Background flush timer cancelled")
+
+ # Final flush of any remaining logs
+ try:
+ await self.flush()
+ except Exception:
+ logger.exception("Error during final flush on close")
+
+ logger.info("HFDatasetWriter closed")
diff --git a/src/rag_chatbot/qlog/models.py b/src/rag_chatbot/qlog/models.py
index c173a19a18a17412092bf8f2f0474b2e44e53d8c..21019fe40d49fdb7f5c772022e9c80cd847073e0 100644
--- a/src/rag_chatbot/qlog/models.py
+++ b/src/rag_chatbot/qlog/models.py
@@ -2,20 +2,64 @@
This module defines the QueryLog model for structured logging of
queries and responses. The model captures all relevant information
-for analytics while avoiding any PII.
+for analytics while strictly avoiding any PII (Personally Identifiable
+Information).
+
+Privacy Policy:
+ This module is designed with privacy as a core principle. The QueryLog
+ model intentionally EXCLUDES the following data to protect user privacy:
+ - IP addresses
+ - User identifiers (session IDs, user IDs, cookies)
+ - HTTP headers (User-Agent, Referer, etc.)
+ - Session data
+ - Any other data that could identify or track users
+
+ Only anonymized, aggregate-safe data is captured:
+ - The question text (assumed to be anonymized if needed)
+ - The generated answer
+ - Source document references
+ - The model that generated the response
+ - Timestamp for temporal analytics
Lazy Loading:
- Pydantic is loaded on first use.
+ Pydantic is loaded on first use via a factory function pattern.
+ This avoids import overhead for CLI tools and scripts that don't
+ need the query logging functionality.
+
+JSONL Schema:
+ The QueryLog model serializes to JSONL format with the following fields:
+ - timestamp: ISO 8601 datetime string (e.g., "2024-01-15T10:30:00Z")
+ - question: User's query string
+ - answer: LLM-generated response string
+ - sources: List of source references (format: "filename:pN")
+ - model_name: Name of the model that generated the response
-Note:
-----
- This is a placeholder that will be fully implemented in Step 4.3.
+Example:
+-------
+ >>> from datetime import datetime, UTC
+ >>> from rag_chatbot.qlog.models import QueryLog
+ >>>
+ >>> log = QueryLog(
+ ... timestamp=datetime.now(UTC),
+ ... question="What is PMV?",
+ ... answer="PMV (Predicted Mean Vote) is a thermal comfort index...",
+ ... sources=["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... )
+ >>> log.to_dict()
+ {
+ "timestamp": "2024-01-15T10:30:00Z",
+ "question": "What is PMV?",
+ "answer": "PMV (Predicted Mean Vote) is a thermal comfort index...",
+ "sources": ["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ "model_name": "gemini-2.5-flash-lite",
+ }
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import datetime
@@ -26,83 +70,378 @@ if TYPE_CHECKING:
__all__: list[str] = ["QueryLog"]
+# =============================================================================
+# Pydantic Model Factory (Lazy Loading)
+# =============================================================================
+# This factory function creates the QueryLog Pydantic model lazily to avoid
+# importing Pydantic at module load time. This follows the project's lazy
+# loading pattern used throughout the codebase (see api/sse.py, api/routes/query.py).
+# =============================================================================
+
+
+def _create_query_log_model() -> type:
+ """Create the QueryLog Pydantic model.
+
+ This factory function creates the model class with lazy imports
+ to avoid loading Pydantic at module import time. The model is
+ designed for logging query/response pairs to a JSONL dataset
+ for analytics purposes.
+
+ Returns:
+ -------
+ type: The QueryLog Pydantic model class.
+
+ Note:
+ ----
+ The returned class includes a custom `to_dict()` method that
+ formats the timestamp as ISO 8601 and returns a JSON-serializable
+ dictionary suitable for JSONL output.
+
+ """
+ # Import datetime for Pydantic's runtime type resolution of the timestamp field.
+ # ruff doesn't detect this usage since Pydantic resolves types at runtime.
+ from datetime import datetime # noqa: F401 - Used by Pydantic field annotation
+
+ from pydantic import BaseModel, ConfigDict, Field
+
+ class _QueryLog(BaseModel):
+ """Model for query log entries in the RAG chatbot system.
+
+ This model captures structured data about each query-response pair
+ for analytics and system monitoring. It is designed to be serialized
+ to JSONL format and stored in a HuggingFace dataset.
+
+ Privacy Notice:
+ This model intentionally EXCLUDES any PII or tracking data:
+ - NO IP addresses
+ - NO user identifiers
+ - NO HTTP headers
+ - NO session data
+ - NO cookies or tokens
+
+ Only the query content, response, sources, model name, and timestamp
+ are captured. The question field should be treated as potentially
+ containing user input and may need additional sanitization in the
+ logging pipeline if PII filtering is required.
+
+ Attributes:
+ ----------
+ timestamp : datetime
+ When the query was processed. Stored in UTC and serialized
+ to ISO 8601 format (e.g., "2024-01-15T10:30:00Z").
+ question : str
+ The user's original query text. This is the question that
+ was submitted to the RAG chatbot.
+ answer : str
+ The LLM-generated response text. This is the complete
+ answer returned to the user.
+ sources : list[str]
+ List of source document references that were used to generate
+ the response. Format: "filename:pN" (e.g., "ASHRAE_55.pdf:p12").
+ model_name : str
+ The name of the LLM model that generated the response
+ (e.g., "gemini-2.5-flash-lite", "llama-3.3-70b-versatile").
+
+ Example:
+ -------
+ >>> from datetime import datetime, UTC
+ >>> log = _QueryLog(
+ ... timestamp=datetime.now(UTC),
+ ... question="What is PMV?",
+ ... answer="PMV (Predicted Mean Vote) is a thermal comfort index...",
+ ... sources=["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... )
+ >>> log.to_dict()
+ {
+ "timestamp": "2024-01-15T10:30:00Z",
+ "question": "What is PMV?",
+ ...
+ }
+
+ """
+
+ # =====================================================================
+ # Model Configuration
+ # =====================================================================
+ # - extra="forbid": Reject any fields not defined in the model
+ # - frozen=True: Make instances immutable after creation
+ # - json_schema_extra: Provide example data for documentation
+ # =====================================================================
+ model_config = ConfigDict(
+ extra="forbid",
+ frozen=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "timestamp": "2024-01-15T10:30:00Z",
+ "question": "What is PMV?",
+ "answer": (
+ "PMV (Predicted Mean Vote) is a thermal comfort index "
+ "that predicts the mean value of votes of a large group "
+ "of people on a 7-point thermal sensation scale."
+ ),
+ "sources": ["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ "model_name": "gemini-2.5-flash-lite",
+ }
+ ]
+ },
+ )
+
+ # =====================================================================
+ # Model Fields
+ # =====================================================================
+ # Each field has a description for documentation and validation.
+ # =====================================================================
+
+ timestamp: datetime = Field(
+ ...,
+ description=(
+ "When the query was processed. Should be in UTC timezone. "
+ "Serialized to ISO 8601 format in to_dict() output."
+ ),
+ )
+
+ question: str = Field(
+ ...,
+ min_length=1,
+ description=(
+ "The user's original query text. This is the question that "
+ "was submitted to the RAG chatbot for answering."
+ ),
+ )
+
+ answer: str = Field(
+ ...,
+ min_length=1,
+ description=(
+ "The LLM-generated response text. This is the complete "
+ "answer that was returned to the user."
+ ),
+ )
+
+ sources: list[str] = Field(
+ default_factory=list,
+ description=(
+ "List of source document references used to generate the response. "
+ "Format: 'filename:pN' (e.g., 'ASHRAE_55.pdf:p12'). "
+ "Empty list if no sources were used."
+ ),
+ )
+
+ model_name: str = Field(
+ ...,
+ min_length=1,
+ description=(
+ "The name of the LLM model that generated the response. "
+ "Examples: 'gemini-2.5-flash-lite', 'llama-3.3-70b-versatile'."
+ ),
+ )
+
+ # =====================================================================
+ # Instance Methods
+ # =====================================================================
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert the log entry to a JSON-serializable dictionary.
+
+ This method produces a dictionary suitable for JSONL serialization.
+ The timestamp is converted to ISO 8601 format string.
+
+ Returns:
+ -------
+ dict[str, Any]
+ Dictionary with all fields, timestamp formatted as ISO 8601.
+
+ Example:
+ -------
+ >>> log.to_dict()
+ {
+ "timestamp": "2024-01-15T10:30:00Z",
+ "question": "What is PMV?",
+ "answer": "PMV (Predicted Mean Vote) is...",
+ "sources": ["ASHRAE_55.pdf:p12"],
+ "model_name": "gemini-2.5-flash-lite",
+ }
+
+ Note:
+ ----
+ The timestamp is formatted using isoformat() which produces
+ ISO 8601 compliant strings. If the timestamp has timezone info,
+ it will be included (e.g., "+00:00" for UTC).
+
+ """
+ return {
+ "timestamp": self.timestamp.isoformat(),
+ "question": self.question,
+ "answer": self.answer,
+ "sources": list(self.sources), # Ensure it's a plain list
+ "model_name": self.model_name,
+ }
+
+ return _QueryLog
+
+
+# =============================================================================
+# Model Class Cache
+# =============================================================================
+# This module-level variable caches the lazily-created Pydantic model class.
+# The first call to _get_query_log() creates the class; subsequent calls
+# return the cached class.
+# =============================================================================
+
+_query_log_model: type | None = None
+
+
+def _get_query_log() -> type:
+ """Get or create the QueryLog model class.
+
+ This function implements the lazy loading pattern. The Pydantic
+ model class is created on first call and cached for subsequent calls.
+
+ Returns
+ -------
+ type: The QueryLog Pydantic model class.
+
+ """
+ global _query_log_model # noqa: PLW0603
+ if _query_log_model is None:
+ _query_log_model = _create_query_log_model()
+ return _query_log_model
+
+
+# =============================================================================
+# Public Model Class (Lazy Proxy)
+# =============================================================================
+# This class acts as a proxy that defers model creation until first use.
+# This enables lazy loading while maintaining the appearance of a regular class.
+# =============================================================================
+
+
class QueryLog:
- """Model for query log entries.
+ """Model for query log entries in the RAG chatbot system.
+
+ This is a lazy-loading proxy class. The actual Pydantic model is
+ created on first use to avoid importing Pydantic at module load time.
+
+ This model captures structured data about each query-response pair
+ for analytics and system monitoring. It is designed to be serialized
+ to JSONL format and stored in a HuggingFace dataset.
- This model captures all relevant information about a query
- and its response for analytics purposes. The model is designed
- to NOT capture any PII.
+ Privacy Notice:
+ This model intentionally EXCLUDES any PII or tracking data:
+ - NO IP addresses
+ - NO user identifiers
+ - NO HTTP headers
+ - NO session data
+ - NO cookies or tokens
+
+ Only the query content, response, sources, model name, and timestamp
+ are captured.
Attributes:
----------
- query: The user's question (anonymized if needed).
- answer: The generated response.
- timestamp: When the query was processed.
- provider: LLM provider that generated the response.
- model: Specific model used.
- chunks_retrieved: Number of context chunks retrieved.
- chunk_ids: IDs of retrieved chunks.
- latency_ms: Total response time in milliseconds.
- tokens_used: Number of tokens consumed.
+ timestamp : datetime
+ When the query was processed. Should be in UTC timezone.
+ question : str
+ The user's original query text.
+ answer : str
+ The LLM-generated response text.
+ sources : list[str]
+ List of source references (format: "filename:pN").
+ model_name : str
+ The name of the model that generated the response.
Example:
-------
+ >>> from datetime import datetime, UTC
>>> log = QueryLog(
- ... query="What is PMV?",
- ... answer="PMV stands for...",
- ... provider="gemini",
- ... ...
+ ... timestamp=datetime.now(UTC),
+ ... question="What is PMV?",
+ ... answer="PMV (Predicted Mean Vote) is a thermal comfort index...",
+ ... sources=["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ ... model_name="gemini-2.5-flash-lite",
... )
-
- Note:
- ----
- This class will be converted to a Pydantic model in Step 4.3.
+ >>> log.to_dict()
+ {
+ "timestamp": "2024-01-15T10:30:00Z",
+ "question": "What is PMV?",
+ "answer": "PMV (Predicted Mean Vote) is a thermal comfort index...",
+ "sources": ["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ "model_name": "gemini-2.5-flash-lite",
+ }
"""
- def __init__( # noqa: PLR0913
- self,
- query: str,
- answer: str,
- timestamp: datetime,
- provider: str,
- model: str,
- chunks_retrieved: int,
- chunk_ids: list[str],
- latency_ms: int,
- tokens_used: int,
- ) -> None:
- """Initialize a query log entry.
+ def __new__(cls, **kwargs: object) -> QueryLog:
+ """Create a new QueryLog instance.
Args:
----
- query: The user's question.
- answer: The generated response.
- timestamp: When the query was processed.
- provider: LLM provider used.
- model: Specific model used.
- chunks_retrieved: Number of chunks retrieved.
- chunk_ids: IDs of retrieved chunks.
- latency_ms: Response time in milliseconds.
- tokens_used: Tokens consumed.
+ **kwargs: Field values for the model. Required fields:
+ - timestamp: datetime
+ - question: str
+ - answer: str
+ - model_name: str
+ Optional fields:
+ - sources: list[str] (defaults to empty list)
+
+ Returns:
+ -------
+ QueryLog: A QueryLog Pydantic model instance.
Raises:
------
- NotImplementedError: QueryLog will be implemented in Step 4.3.
+ pydantic.ValidationError: If required fields are missing or
+ field values fail validation.
"""
- raise NotImplementedError("QueryLog will be implemented in Step 4.3")
+ model_class = _get_query_log()
+ return model_class(**kwargs) # type: ignore[no-any-return]
- def to_dict(self) -> dict[str, object]:
- """Convert the log entry to a dictionary.
+ @classmethod
+ def model_validate(cls, obj: object) -> QueryLog:
+ """Validate and create a model from an object.
- Returns
+ This method validates an object (dict, another model instance, etc.)
+ and creates a QueryLog instance if validation succeeds.
+
+ Args:
+ ----
+ obj: Object to validate. Can be a dict with the required fields
+ or another object with matching attributes.
+
+ Returns:
-------
- Dictionary representation of the log entry.
+ QueryLog: Validated QueryLog instance.
- Raises
+ Raises:
------
- NotImplementedError: Method will be implemented in Step 4.3.
+ pydantic.ValidationError: If validation fails.
+
+ Example:
+ -------
+ >>> data = {
+ ... "timestamp": "2024-01-15T10:30:00Z",
+ ... "question": "What is PMV?",
+ ... "answer": "PMV is...",
+ ... "sources": ["ASHRAE_55.pdf:p12"],
+ ... "model_name": "gemini-2.5-flash-lite",
+ ... }
+ >>> log = QueryLog.model_validate(data)
+
+ """
+ model_class = _get_query_log()
+ return model_class.model_validate(obj) # type: ignore[attr-defined, no-any-return]
+
+ @classmethod
+ def model_json_schema(cls) -> dict[str, Any]:
+ """Get the JSON schema for the QueryLog model.
+
+ Returns
+ -------
+ dict[str, Any]: JSON schema dictionary describing the model structure.
"""
- raise NotImplementedError("to_dict() will be implemented in Step 4.3")
+ model_class = _get_query_log()
+ return model_class.model_json_schema() # type: ignore[attr-defined, no-any-return]
diff --git a/src/rag_chatbot/qlog/service.py b/src/rag_chatbot/qlog/service.py
new file mode 100644
index 0000000000000000000000000000000000000000..17f21353f596c555cfa56f3c5d87423a905c3e5c
--- /dev/null
+++ b/src/rag_chatbot/qlog/service.py
@@ -0,0 +1,490 @@
+"""Query logging service for the RAG chatbot.
+
+This module provides a singleton QueryLogService that manages the complete
+lifecycle of query logging. It wraps the HFDatasetWriter with a simple API
+for use in FastAPI routes.
+
+Singleton Pattern Rationale:
+ The QueryLogService uses a singleton pattern because:
+ 1. **Single Writer Instance**: Only one HFDatasetWriter should exist to ensure
+ proper batching and flush coordination. Multiple writers would cause race
+ conditions and duplicate uploads.
+ 2. **Shared State**: The flush timer and log buffer must be shared across all
+ API endpoints that log queries. A singleton naturally provides this sharing.
+ 3. **Lifecycle Management**: The service must be started at application startup
+ and stopped at shutdown. A singleton makes this lifecycle easy to manage.
+ 4. **Resource Efficiency**: Creating multiple writer instances would waste
+ memory and create redundant background tasks.
+
+Non-Blocking Design:
+ The log_query() method is designed to NEVER block the API response:
+ 1. It creates the QueryLog model synchronously (fast)
+ 2. Calls writer.log() which only appends to an in-memory buffer (fast)
+ 3. The actual HuggingFace upload happens in the background when:
+ - The buffer reaches batch_size, OR
+ - The flush interval timer fires, OR
+ - The service is stopped (final flush)
+
+ This design ensures that query logging adds minimal latency (<1ms) to
+ API responses, even when the HuggingFace upload is slow or failing.
+
+Lifecycle Management:
+ The service integrates with FastAPI's lifespan events:
+
+ ::
+
+ from contextlib import asynccontextmanager
+ from fastapi import FastAPI
+ from rag_chatbot.qlog import on_startup, on_shutdown
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ await on_startup()
+ yield # Application runs here
+ await on_shutdown()
+
+ app = FastAPI(lifespan=lifespan)
+
+ The start() method:
+ - Creates the HFDatasetWriter with settings from config
+ - Starts the background flush timer
+ - Sets is_running to True
+
+ The stop() method:
+ - Stops the background flush timer
+ - Flushes any remaining logs to HuggingFace
+ - Closes the writer
+ - Sets is_running to False
+
+Error Handling Philosophy:
+ Query logging is a SECONDARY concern - it should NEVER break the main
+ application. Therefore:
+ - All methods catch and log exceptions internally
+ - No exceptions are raised to callers
+ - If the service is not running, log_query() silently returns
+ - If HuggingFace upload fails, logs are stored locally for retry
+
+ This "fail-safe" design ensures that users always get their answers,
+ even if the logging infrastructure is having problems.
+
+Example:
+-------
+ >>> from rag_chatbot.qlog import get_query_log_service
+ >>>
+ >>> # In your API route
+ >>> service = get_query_log_service()
+ >>> await service.log_query(
+ ... question="What is PMV?",
+ ... answer="PMV (Predicted Mean Vote) is...",
+ ... sources=["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... ) # Non-blocking, returns immediately
+
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .hf_writer import HFDatasetWriter
+ from .models import QueryLog
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "QueryLogService",
+ "get_query_log_service",
+ "on_startup",
+ "on_shutdown",
+]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# QueryLogService Class
+# =============================================================================
+
+
+class QueryLogService:
+ """Singleton service for managing query logging lifecycle.
+
+ This service provides a simple interface for logging queries from API
+ routes while managing the underlying HFDatasetWriter lifecycle. It
+ handles initialization, configuration, and cleanup automatically.
+
+ The service follows a singleton pattern - only one instance should exist
+ per application. Use get_query_log_service() to obtain the instance.
+
+ Design Principles:
+ 1. **Non-Blocking**: log_query() never blocks. It adds entries to a
+ buffer and returns immediately (<1ms).
+ 2. **Fail-Safe**: All errors are caught and logged. Logging issues
+ never crash the application or affect user responses.
+ 3. **Lazy Initialization**: Settings and writer are loaded on first
+ use (start() call), not at import time.
+ 4. **Clean Shutdown**: stop() ensures all pending logs are flushed
+ before the application exits.
+
+ Attributes:
+ ----------
+ is_running : bool
+ True if the service has been started and not stopped.
+
+ Example:
+ -------
+ >>> service = get_query_log_service()
+ >>> await service.start() # Call once at app startup
+ >>> await service.log_query(
+ ... question="What is PMV?",
+ ... answer="PMV is...",
+ ... sources=["doc.pdf:p1"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... )
+ >>> await service.stop() # Call once at app shutdown
+
+ """
+
+ def __init__(self) -> None:
+ """Initialize the QueryLogService.
+
+ Note:
+ ----
+ This constructor does NOT start the service. You must call
+ start() before logging queries. This allows the service to
+ be constructed at module load time without side effects.
+
+ """
+ # Writer instance (created in start())
+ self._writer: HFDatasetWriter | None = None
+
+ # Running state flag
+ self._running: bool = False
+
+ logger.debug("QueryLogService instance created (not yet started)")
+
+ @property
+ def is_running(self) -> bool:
+ """Check if the service is currently running.
+
+ Returns
+ -------
+ True if start() has been called and stop() has not been called.
+ False otherwise.
+
+ """
+ return self._running
+
+ async def start(self) -> None:
+ """Start the query logging service.
+
+ Initialize the HFDatasetWriter with configuration from Settings
+ and start the background flush timer. This method should be called
+ once during FastAPI application startup.
+
+ This method is idempotent - calling it multiple times has no effect
+ if the service is already running.
+
+ Configuration is loaded from Settings:
+ - hf_qlog_repo: HuggingFace repository for logs
+ - qlog_batch_size: Number of logs to batch
+ - qlog_flush_interval_seconds: Flush timer interval
+ - hf_token: Authentication token
+
+ Note:
+ ----
+ If Settings or HFDatasetWriter fail to load (e.g., missing
+ dependencies), the error is logged but not raised. The service
+ will remain in a non-running state.
+
+ Example:
+ -------
+ >>> service = get_query_log_service()
+ >>> await service.start()
+ >>> assert service.is_running
+
+ """
+ if self._running:
+ logger.debug("QueryLogService already running, ignoring start()")
+ return
+
+ try:
+ # Lazy import Settings to defer pydantic_settings loading
+ from ..config.settings import Settings
+
+ settings = Settings()
+
+ # Lazy import HFDatasetWriter
+ from .hf_writer import HFDatasetWriter
+
+ # Create writer with configuration from settings
+ self._writer = HFDatasetWriter(
+ repo_id=settings.hf_qlog_repo,
+ batch_size=settings.qlog_batch_size,
+ flush_interval=float(settings.qlog_flush_interval_seconds),
+ hf_token=settings.hf_token,
+ )
+
+ # Start the background flush timer
+ self._writer.start_background_flush()
+
+ # Mark service as running
+ self._running = True
+
+ logger.info(
+ "QueryLogService started: repo=%s, batch_size=%d, interval=%ds",
+ settings.hf_qlog_repo,
+ settings.qlog_batch_size,
+ settings.qlog_flush_interval_seconds,
+ )
+
+ except Exception:
+ logger.exception("Failed to start QueryLogService")
+ # Service remains not running, log_query() will silently return
+
+ async def stop(self) -> None:
+ """Stop the query logging service.
+
+ Stop the background flush timer and flush any remaining logs to
+ HuggingFace. This method should be called once during FastAPI
+ application shutdown.
+
+ This method is idempotent - calling it multiple times has no effect
+ if the service is already stopped.
+
+ Note:
+ ----
+ Any errors during shutdown are logged but not raised. This
+ ensures the application can shut down cleanly even if there
+ are issues with log flushing.
+
+ Example:
+ -------
+ >>> service = get_query_log_service()
+ >>> await service.stop()
+ >>> assert not service.is_running
+
+ """
+ if not self._running:
+ logger.debug("QueryLogService not running, ignoring stop()")
+ return
+
+ try:
+ if self._writer is not None:
+ # Close writer (flushes remaining logs and stops timer)
+ await self._writer.close()
+ self._writer = None
+
+ logger.info("QueryLogService stopped")
+
+ except Exception:
+ logger.exception("Error during QueryLogService shutdown")
+
+ finally:
+ # Always mark as not running, even if close() failed
+ self._running = False
+
+ async def log_query(
+ self,
+ question: str,
+ answer: str,
+ sources: list[str],
+ model_name: str,
+ ) -> None:
+ """Log a query-response pair (non-blocking).
+
+ Create a QueryLog entry and add it to the write buffer. This method
+ returns immediately without waiting for the log to be uploaded to
+ HuggingFace. The actual upload happens in the background.
+
+ This method is designed for "fire-and-forget" usage in API routes.
+ It should be called after generating a response but before returning
+ to the user.
+
+ Args:
+ ----
+ question: The user's original query text.
+ answer: The LLM-generated response text.
+ sources: List of source references used to generate the answer.
+ Format: ["filename.pdf:pN", ...] (e.g., ["ASHRAE_55.pdf:p12"])
+ model_name: Name of the LLM model that generated the response.
+ Examples: "gemini-2.5-flash-lite", "llama-3.3-70b-versatile"
+
+ Note:
+ ----
+ - If the service is not running, this method silently returns.
+ - Any errors are logged but never raised.
+ - The timestamp is automatically set to the current UTC time.
+
+ Example:
+ -------
+ >>> service = get_query_log_service()
+ >>> await service.log_query(
+ ... question="What is PMV?",
+ ... answer="PMV (Predicted Mean Vote) is a thermal comfort index...",
+ ... sources=["ASHRAE_55.pdf:p12", "ISO_7730.pdf:p5"],
+ ... model_name="gemini-2.5-flash-lite",
+ ... ) # Returns immediately, upload happens in background
+
+ """
+ # Silently return if service is not running
+ if not self._running or self._writer is None:
+ logger.debug("QueryLogService not running, skipping log_query()")
+ return
+
+ try:
+ # Lazy import QueryLog to defer pydantic loading
+ from .models import QueryLog
+
+ # Create QueryLog with current UTC timestamp
+ entry: QueryLog = QueryLog(
+ timestamp=datetime.now(UTC),
+ question=question,
+ answer=answer,
+ sources=sources,
+ model_name=model_name,
+ )
+
+ # Delegate to writer (non-blocking, adds to buffer)
+ await self._writer.log(entry)
+
+ logger.debug(
+ "Query logged: model=%s, sources=%d",
+ model_name,
+ len(sources),
+ )
+
+ except Exception:
+ # Log error but never raise - logging should not break the app
+ logger.exception("Failed to log query")
+
+
+# =============================================================================
+# Singleton Instance Management
+# =============================================================================
+# The service uses a module-level instance variable for the singleton pattern.
+# This is simpler and more Pythonic than a class-based singleton implementation.
+# =============================================================================
+
+# Module-level singleton instance (created on first access)
+_service_instance: QueryLogService | None = None
+
+
+def get_query_log_service() -> QueryLogService:
+ """Get the singleton QueryLogService instance.
+
+ Returns the global QueryLogService instance, creating it on first access.
+ This function is the recommended way to obtain the service instance.
+
+ The singleton pattern ensures:
+ - Only one HFDatasetWriter exists (proper batching)
+ - Flush timer and buffer are shared across all callers
+ - Lifecycle (start/stop) is managed centrally
+
+ Returns:
+ -------
+ The global QueryLogService instance.
+
+ Example:
+ -------
+ >>> service = get_query_log_service()
+ >>> await service.start()
+ >>> # ... use service in multiple routes ...
+ >>> await service.stop()
+
+ Note:
+ ----
+ The returned service may not be running. Check service.is_running
+ or call start() before logging queries.
+
+ """
+ global _service_instance # noqa: PLW0603
+ if _service_instance is None:
+ _service_instance = QueryLogService()
+ logger.debug("Created singleton QueryLogService instance")
+ return _service_instance
+
+
+# =============================================================================
+# FastAPI Integration Hooks
+# =============================================================================
+# These functions provide a clean interface for FastAPI lifespan integration.
+# They should be called from the application's lifespan context manager.
+# =============================================================================
+
+
+async def on_startup() -> None:
+ """Start the query logging service (FastAPI lifespan hook).
+
+ Call this function from your FastAPI lifespan startup to initialize
+ the query logging service. It obtains the singleton service instance
+ and starts it.
+
+ Example:
+ -------
+ ::
+
+ from contextlib import asynccontextmanager
+ from fastapi import FastAPI
+ from rag_chatbot.qlog import on_startup, on_shutdown
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ await on_startup()
+ yield # Application runs here
+ await on_shutdown()
+
+ app = FastAPI(lifespan=lifespan)
+
+ Note:
+ ----
+ This function never raises exceptions. If the service fails to
+ start, the error is logged and the application continues without
+ query logging.
+
+ """
+ logger.info("Starting query logging service...")
+ service = get_query_log_service()
+ await service.start()
+
+
+async def on_shutdown() -> None:
+ """Stop the query logging service (FastAPI lifespan hook).
+
+ Call this function from your FastAPI lifespan shutdown to cleanly
+ stop the query logging service. It flushes any pending logs and
+ releases resources.
+
+ Example:
+ -------
+ ::
+
+ from contextlib import asynccontextmanager
+ from fastapi import FastAPI
+ from rag_chatbot.qlog import on_startup, on_shutdown
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ await on_startup()
+ yield # Application runs here
+ await on_shutdown()
+
+ app = FastAPI(lifespan=lifespan)
+
+ Note:
+ ----
+ This function never raises exceptions. If the service fails to
+ stop cleanly, the error is logged and the application continues
+ shutting down.
+
+ """
+ logger.info("Stopping query logging service...")
+ service = get_query_log_service()
+ await service.stop()
diff --git a/src/rag_chatbot/retrieval/__init__.py b/src/rag_chatbot/retrieval/__init__.py
index 017d8fd75c02a6d8911a2c5d9b49ea9c8121223d..fef5af9a9f232816734df2d80d43f061bb185cef 100644
--- a/src/rag_chatbot/retrieval/__init__.py
+++ b/src/rag_chatbot/retrieval/__init__.py
@@ -8,6 +8,8 @@ document store using a hybrid approach combining:
Components:
- FAISSIndex: Dense vector similarity search
+ - DenseRetriever: Semantic retrieval using FAISS and BGE embeddings
+ - ChunkStore: Storage and lookup for chunk metadata
- BM25Retriever: Sparse keyword-based retrieval
- HybridRetriever: Combines dense and sparse methods
@@ -20,16 +22,22 @@ Functions:
- normalize_text: Utility function for cleaning and normalizing text
Lazy Loading:
- Heavy dependencies (faiss, rank_bm25) are loaded on first access
- using __getattr__. This ensures fast import times and minimal
- memory usage until retrieval is needed.
+ Heavy dependencies (faiss, rank_bm25, torch, sentence-transformers)
+ are loaded on first access using __getattr__. This ensures fast import
+ times and minimal memory usage until retrieval is needed.
Example:
-------
- >>> from rag_chatbot.retrieval import HybridRetriever, RetrievalQuery
- >>> retriever = HybridRetriever()
- >>> query = RetrievalQuery(query="thermal comfort calculation", top_k=5)
- >>> results = retriever.retrieve(query.query, query.top_k)
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import (
+ ... DenseRetriever, FAISSIndex, ChunkStore, RetrievalQuery
+ ... )
+ >>> # Load index and chunks
+ >>> index = FAISSIndex.load(Path("data/index.faiss"))
+ >>> chunks = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> # Create retriever and search
+ >>> retriever = DenseRetriever(faiss_index=index, chunk_store=chunks)
+ >>> results = retriever.retrieve("thermal comfort calculation", top_k=5)
"""
@@ -42,29 +50,52 @@ from .models import RetrievalQuery, RetrievalResult, Retriever, normalize_text
if TYPE_CHECKING:
from .bm25 import BM25Retriever
+ from .chunk_store import ChunkStore
+ from .dense import DenseRetriever
+ from .factory import RetrieverWithReranker, get_default_retriever
from .faiss_index import FAISSIndex
from .hybrid import HybridRetriever
+ from .reranker import Reranker
+
+# Note: HybridRetriever now uses RetrievalResult from models.py
+# (the placeholder RetrievalResult class has been removed from hybrid.py)
# =============================================================================
# Module Exports
# =============================================================================
__all__: list[str] = [
+ # Index and storage components (lazy loaded)
"FAISSIndex",
+ "ChunkStore",
+ # Retriever implementations (lazy loaded)
+ "DenseRetriever",
"BM25Retriever",
"HybridRetriever",
+ # Optional reranker (lazy loaded)
+ "Reranker",
+ # Factory function and wrapper (lazy loaded)
+ "get_default_retriever",
+ "RetrieverWithReranker",
+ # Models (lightweight, imported directly)
"RetrievalResult",
"RetrievalQuery",
"Retriever",
+ # Utility functions (lightweight, imported directly)
"normalize_text",
]
-def __getattr__(name: str) -> object:
+def __getattr__(name: str) -> object: # noqa: PLR0911
"""Lazy load module exports on first access.
This function is called when an attribute is not found in the module's
- namespace. It enables lazy loading of heavy dependencies like faiss
- and rank_bm25.
+ namespace. It enables lazy loading of heavy dependencies like faiss,
+ rank_bm25, torch, and sentence-transformers.
+
+ The lazy loading pattern ensures:
+ - Fast import times (no heavy dependencies at import)
+ - Minimal memory usage until retrieval is needed
+ - Compatibility with environments missing optional dependencies
Args:
----
@@ -79,17 +110,48 @@ def __getattr__(name: str) -> object:
AttributeError: If the attribute is not a valid export.
"""
+ # Index and storage components
if name == "FAISSIndex":
from .faiss_index import FAISSIndex
return FAISSIndex
+
+ if name == "ChunkStore":
+ from .chunk_store import ChunkStore
+
+ return ChunkStore
+
+ # Retriever implementations
+ if name == "DenseRetriever":
+ from .dense import DenseRetriever
+
+ return DenseRetriever
+
if name == "BM25Retriever":
from .bm25 import BM25Retriever
return BM25Retriever
+
if name == "HybridRetriever":
from .hybrid import HybridRetriever
return HybridRetriever
+
+ if name == "Reranker":
+ from .reranker import Reranker
+
+ return Reranker
+
+ # Factory function and wrapper
+ if name == "get_default_retriever":
+ from .factory import get_default_retriever
+
+ return get_default_retriever
+
+ if name == "RetrieverWithReranker":
+ from .factory import RetrieverWithReranker
+
+ return RetrieverWithReranker
+
msg = f"module {__name__!r} has no attribute {name!r}" # pragma: no cover
raise AttributeError(msg) # pragma: no cover
diff --git a/src/rag_chatbot/retrieval/bm25.py b/src/rag_chatbot/retrieval/bm25.py
index 5939dc9836418af2979ee74efa9ea29cbb36a9d7..593e00b1e07048adc1b740c7aee1ae73fad81f8a 100644
--- a/src/rag_chatbot/retrieval/bm25.py
+++ b/src/rag_chatbot/retrieval/bm25.py
@@ -1,27 +1,73 @@
"""BM25 sparse retrieval for keyword-based search.
-This module provides the BM25Retriever class for sparse retrieval
-using the BM25 algorithm. BM25 is effective for:
- - Keyword matching
- - Exact term retrieval
- - Handling out-of-vocabulary terms
+This module provides the BM25Retriever class for sparse retrieval using the
+BM25 (Best Match 25) algorithm. BM25 is a probabilistic ranking function that
+scores documents based on term frequency (TF) and inverse document frequency (IDF).
+
+BM25 is particularly effective for:
+ - Keyword matching: Exact term retrieval where semantic similarity may fail
+ - Out-of-vocabulary terms: Technical terms or acronyms not in embedding vocab
+ - Hybrid retrieval: Complementing dense embeddings with sparse signals
+
+The BM25 scoring formula is:
+ score(D,Q) = sum_{i=1}^{n} IDF(q_i) * (f(q_i,D) * (k1+1)) /
+ (f(q_i,D) + k1 * (1-b+b*|D|/avgdl))
+
+Where:
+ - f(q_i,D) = term frequency of query term q_i in document D
+ - |D| = length of document D in words
+ - avgdl = average document length across the corpus
+ - k1 = term frequency saturation parameter (default: 1.5)
+ - b = document length normalization parameter (default: 0.75)
+ - IDF(q_i) = log((N - n(q_i) + 0.5) / (n(q_i) + 0.5))
+ - N = total number of documents
+ - n(q_i) = number of documents containing term q_i
+
+Design Decisions:
+ - Lazy loading: rank_bm25 is imported on first use to avoid overhead
+ - Text normalization: Uses normalize_text from models.py plus tokenization
+ - Score normalization: Raw BM25 scores normalized to [0, 1] using min-max
+ - Persistence: Index saved via pickle with tokenized corpus (not BM25 object)
Lazy Loading:
- rank_bm25 is loaded on first use to avoid import overhead when
- sparse retrieval is not needed.
-
-Note:
-----
- This is a placeholder that will be fully implemented in Step 2.5.
+ The rank_bm25 library is loaded on first use (build or load) to avoid
+ import overhead when BM25 retrieval is not needed. This follows the
+ project convention for heavy dependencies.
+
+Example:
+-------
+ >>> from rag_chatbot.retrieval import BM25Retriever
+ >>> # Build index from corpus
+ >>> retriever = BM25Retriever(k1=1.5, b=0.75)
+ >>> retriever.build(corpus=["doc1 text", "doc2 text"], chunk_ids=["c1", "c2"])
+ >>> # Retrieve
+ >>> results = retriever.retrieve("search query", top_k=5)
+ >>> for chunk_id, score in results:
+ ... print(f"{chunk_id}: {score:.3f}")
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
+import pickle
+import re
+import string
+from pathlib import Path
+from types import ModuleType
+from typing import TYPE_CHECKING, Any
+
+# Import the normalize_text function from models (lightweight, no heavy deps)
+from .models import normalize_text
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# =============================================================================
if TYPE_CHECKING:
- from pathlib import Path
+ from rank_bm25 import BM25Okapi
# =============================================================================
# Module Exports
@@ -29,34 +75,292 @@ if TYPE_CHECKING:
__all__: list[str] = ["BM25Retriever"]
-class BM25Retriever: # pragma: no cover
- """BM25-based sparse retriever for keyword search.
+# =============================================================================
+# Lazy Loading for Heavy Dependencies
+# =============================================================================
+# The rank_bm25 library is loaded lazily on first use. This pattern ensures:
+# - Fast import times when BM25 is not needed
+# - Minimal memory usage until retrieval starts
+# - Compatibility with environments without rank_bm25 installed
+# =============================================================================
- This class provides methods for building and querying a BM25
- index for sparse retrieval. BM25 complements dense retrieval
- by handling exact keyword matches and out-of-vocabulary terms
- that may not be well represented in embedding space.
+# Global variable to cache the lazily-loaded rank_bm25 module
+# Using None as sentinel value to indicate "not yet loaded"
+_bm25_module: ModuleType | None = None
- The implementation uses rank_bm25 for efficient BM25 scoring
- with support for:
- - Custom tokenization
- - Configurable BM25 parameters (k1, b)
- - Index persistence
- Attributes:
- ----------
- k1: BM25 term frequency saturation parameter.
- b: BM25 document length normalization parameter.
+def _get_bm25_module() -> ModuleType:
+ """Lazily import and cache the rank_bm25 module.
+
+ This function implements lazy loading for the rank_bm25 dependency.
+ On first call, it imports the module and caches it globally. Subsequent
+ calls return the cached module without re-importing.
+
+ The lazy loading pattern ensures that the heavy dependency is only loaded
+ when BM25 functionality is actually needed, improving startup time for
+ applications that may not use BM25 retrieval.
+
+ Returns:
+ -------
+ The rank_bm25 module, cached for subsequent calls.
+
+ Raises:
+ ------
+ ImportError: If rank_bm25 is not installed. Install with:
+ pip install rank-bm25
+ or
+ poetry add rank-bm25
+
+ Example:
+ -------
+ >>> bm25 = _get_bm25_module()
+ >>> index = bm25.BM25Okapi(tokenized_corpus)
+
+ """
+ global _bm25_module # noqa: PLW0603
+
+ # Return cached module if already loaded
+ if _bm25_module is not None:
+ return _bm25_module
+
+ # Import and cache the module on first use.
+ # This may take a moment as rank_bm25 loads numpy dependencies.
+ import rank_bm25 as bm25
+
+ _bm25_module = bm25
+ return _bm25_module
+
+
+# =============================================================================
+# Text Processing Utilities
+# =============================================================================
+# These functions handle text normalization and tokenization for BM25 indexing.
+# Proper text processing is critical for effective keyword matching.
+# =============================================================================
+
+# Pre-compile regex pattern for punctuation removal
+# This is more efficient than using str.translate for each call
+# Matches any punctuation character from string.punctuation
+_PUNCTUATION_PATTERN: re.Pattern[str] = re.compile(f"[{re.escape(string.punctuation)}]")
+
+
+def _tokenize(text: str) -> list[str]:
+ """Tokenize text for BM25 indexing.
+
+ This function performs the following text processing steps:
+ 1. Normalize text using normalize_text (fix whitespace, capitalization)
+ 2. Convert to lowercase for case-insensitive matching
+ 3. Remove punctuation (commas, periods, etc.)
+ 4. Split on whitespace into tokens
+ 5. Filter out empty tokens
+
+ The tokenization strategy is intentionally simple (whitespace splitting)
+ because BM25 works well with basic tokenization, and more sophisticated
+ tokenization (stemming, lemmatization) can sometimes hurt retrieval
+ performance for technical documentation.
+
+ Args:
+ ----
+ text: The text string to tokenize.
+ Can contain any UTF-8 characters including Unicode.
+
+ Returns:
+ -------
+ List of lowercase tokens with punctuation removed.
+ Empty list if text is empty or whitespace-only.
Example:
-------
- >>> retriever = BM25Retriever()
- >>> retriever.build(corpus, chunk_ids)
- >>> results = retriever.retrieve("pmv calculation", top_k=5)
+ >>> _tokenize("Hello, World!")
+ ['hello', 'world']
+ >>> _tokenize("The PMV model is 25.5 degrees.")
+ ['the', 'pmv', 'model', 'is', '255', 'degrees']
+ >>> _tokenize(" ")
+ []
Note:
----
- This class will be fully implemented in Phase 2 (Step 2.5).
+ - Numbers are preserved (not removed) to support queries like "ISO 7730"
+ - Unicode characters are preserved for international text support
+ - Contractions like "don't" become "dont" (apostrophe removed)
+
+ """
+ # Step 1: Apply text normalization from models.py
+ # This fixes extra whitespace, capitalization after periods, etc.
+ normalized = normalize_text(text)
+
+ # Handle empty text after normalization
+ if not normalized:
+ return []
+
+ # Step 2: Convert to lowercase for case-insensitive matching
+ # This ensures "PMV" and "pmv" are treated as the same term
+ lowercased = normalized.lower()
+
+ # Step 3: Remove punctuation using pre-compiled regex
+ # This converts "Hello, world!" to "Hello world"
+ # Punctuation can interfere with term matching
+ without_punctuation = _PUNCTUATION_PATTERN.sub("", lowercased)
+
+ # Step 4: Split on whitespace
+ # This creates individual tokens from the cleaned text
+ # Using split() without arguments splits on any whitespace and removes empty strings
+ tokens = without_punctuation.split()
+
+ # Step 5: Filter out any remaining empty tokens (defensive)
+ # The split() above should handle this, but being explicit is safer
+ return [token for token in tokens if token]
+
+
+# =============================================================================
+# Score Normalization Utilities
+# =============================================================================
+# BM25 raw scores are unbounded positive values. We normalize to [0, 1]
+# for consistency with dense retrieval and the RetrievalResult model.
+# =============================================================================
+
+
+def _normalize_scores(scores: list[float]) -> list[float]:
+ """Normalize BM25 scores to [0, 1] range using min-max normalization.
+
+ BM25 raw scores are positive values that can be arbitrarily large depending
+ on term frequency, document length, and corpus statistics. This function
+ normalizes them to [0, 1] range for consistency with other retrievers.
+
+ Normalization Formula:
+ normalized = (score - min_score) / (max_score - min_score)
+
+ This maps:
+ - Minimum score -> 0.0
+ - Maximum score -> 1.0
+ - All other scores -> proportionally between 0 and 1
+
+ Edge Cases:
+ - Empty list: Returns empty list
+ - Single value: Returns [1.0] (the only result is the "best")
+ - All same values: Returns all 1.0 (all equally relevant)
+ - Max score is 0: Returns all 0.0 (no relevance detected)
+
+ Args:
+ ----
+ scores: List of raw BM25 scores (non-negative floats).
+
+ Returns:
+ -------
+ List of normalized scores in [0.0, 1.0] range.
+
+ Example:
+ -------
+ >>> _normalize_scores([0.0, 0.5, 1.0])
+ [0.0, 0.5, 1.0]
+ >>> _normalize_scores([2.0, 4.0, 6.0])
+ [0.0, 0.5, 1.0]
+ >>> _normalize_scores([5.0, 5.0, 5.0])
+ [1.0, 1.0, 1.0]
+ >>> _normalize_scores([])
+ []
+
+ """
+ # Handle empty list
+ if not scores:
+ return []
+
+ # Find min and max scores for normalization
+ min_score = min(scores)
+ max_score = max(scores)
+
+ # Calculate the range for normalization
+ score_range = max_score - min_score
+
+ # =================================================================
+ # Edge case: All scores are the same (range is 0)
+ # =================================================================
+ # When all documents have the same score, they're equally relevant.
+ # We return 1.0 for all to indicate "best available match".
+ # =================================================================
+ if score_range == 0:
+ # If max_score is also 0, no relevance was detected
+ # Return 0.0 for all in this case
+ if max_score == 0:
+ return [0.0] * len(scores)
+ # Otherwise, all scores are equal and non-zero
+ # Return 1.0 for all (equally "best")
+ return [1.0] * len(scores)
+
+ # Normal case: Apply min-max normalization.
+ # Maps min -> 0.0 and max -> 1.0
+ return [(score - min_score) / score_range for score in scores]
+
+
+# =============================================================================
+# BM25Retriever Class
+# =============================================================================
+
+
+class BM25Retriever:
+ """BM25-based sparse retriever for keyword search.
+
+ This class implements BM25 (Best Match 25) retrieval, a probabilistic ranking
+ function widely used for information retrieval. BM25 complements dense
+ embeddings by handling exact keyword matches and out-of-vocabulary terms
+ that may not be well represented in embedding space.
+
+ The BM25Okapi variant is used (from rank_bm25 library), which implements
+ the standard BM25 scoring with Okapi weighting. Key parameters:
+
+ Parameters
+ ----------
+ k1 : float
+ Term frequency saturation parameter. Controls how quickly term
+ frequency reaches saturation. Higher values give more weight to
+ repeated terms. Typical range: 1.2 to 2.0.
+ - k1 = 0: Binary term presence (TF ignored)
+ - k1 = 1.5 (default): Standard BM25 setting
+ - k1 = 3+: Very high TF weight
+
+ b : float
+ Document length normalization parameter. Controls how much
+ document length affects scoring. Range: 0.0 to 1.0.
+ - b = 0: No length normalization (long docs not penalized)
+ - b = 0.75 (default): Standard BM25 setting
+ - b = 1: Full length normalization
+
+ Lazy Loading:
+ The rank_bm25 library is loaded on first use (build or load) to
+ avoid import overhead when BM25 is not needed.
+
+ Thread Safety:
+ This class is NOT thread-safe. For concurrent access, use separate
+ instances or external synchronization.
+
+ Attributes
+ ----------
+ _k1 : float
+ BM25 k1 parameter (term frequency saturation).
+ _b : float
+ BM25 b parameter (document length normalization).
+ _bm25 : BM25Okapi | None
+ The BM25 index (None until build() is called).
+ _tokenized_corpus : list[list[str]] | None
+ Tokenized documents (None until build() is called).
+ _chunk_ids : list[str] | None
+ Chunk identifiers mapping indices to IDs.
+
+ Example
+ -------
+ >>> retriever = BM25Retriever(k1=1.5, b=0.75)
+ >>> retriever.build(
+ ... corpus=["The PMV model predicts thermal sensation."],
+ ... chunk_ids=["chunk_001"]
+ ... )
+ >>> results = retriever.retrieve("PMV model", top_k=5)
+ >>> chunk_id, score = results[0]
+ >>> print(f"Best match: {chunk_id} with score {score:.3f}")
+
+ See Also
+ --------
+ - https://en.wikipedia.org/wiki/Okapi_BM25
+ - https://github.com/dorianbrown/rank_bm25
"""
@@ -65,96 +369,510 @@ class BM25Retriever: # pragma: no cover
k1: float = 1.5,
b: float = 0.75,
) -> None:
- """Initialize the BM25 retriever.
+ """Initialize the BM25 retriever with configurable parameters.
+
+ Creates a new BM25Retriever instance with the specified BM25 parameters.
+ The index is NOT built during initialization - call build() to create
+ the index, or load() to restore a saved index.
+
+ This follows the lazy loading pattern: no heavy dependencies are loaded
+ during __init__. The rank_bm25 library is only imported when build()
+ or load() is called.
Args:
----
- k1: Term frequency saturation parameter. Higher values
- give more weight to term frequency. Defaults to 1.5.
+ k1: Term frequency saturation parameter. Higher values give more
+ weight to term frequency. Must be non-negative.
+ Defaults to 1.5 (standard BM25 setting).
b: Document length normalization parameter. 0 means no
- normalization, 1 means full normalization. Defaults to 0.75.
+ normalization, 1 means full normalization. Should be
+ in [0, 1] range. Defaults to 0.75 (standard BM25 setting).
- Raises:
- ------
- NotImplementedError: BM25Retriever will be implemented in Step 2.5.
+ Example:
+ -------
+ >>> # Default parameters
+ >>> retriever = BM25Retriever()
+
+ >>> # Custom parameters for short documents
+ >>> retriever = BM25Retriever(k1=1.2, b=0.5)
+
+ >>> # High term frequency weight
+ >>> retriever = BM25Retriever(k1=2.5, b=0.75)
+
+ Note:
+ ----
+ - The retriever is not usable until build() or load() is called
+ - No validation is performed on k1/b ranges (rank_bm25 handles this)
"""
- self._k1 = k1
- self._b = b
- raise NotImplementedError("BM25Retriever will be implemented in Step 2.5")
+ # =================================================================
+ # Store BM25 parameters
+ # =================================================================
+ # These parameters are used when building the BM25Okapi index
+ # k1: Controls term frequency saturation (default 1.5)
+ # b: Controls document length normalization (default 0.75)
+ # =================================================================
+ self._k1: float = k1
+ self._b: float = b
+
+ # =================================================================
+ # Initialize state as None (not yet built)
+ # =================================================================
+ # The BM25 index and related data structures are created in build()
+ # or restored in load(). Until then, these are None.
+ # =================================================================
+
+ # The BM25Okapi index from rank_bm25
+ # This is the core data structure for BM25 scoring
+ self._bm25: BM25Okapi | None = None
+
+ # Tokenized version of the corpus
+ # Stored for persistence (BM25Okapi is not directly picklable)
+ self._tokenized_corpus: list[list[str]] | None = None
+
+ # Mapping from corpus indices to chunk IDs
+ # Used to return chunk_ids in retrieve() results
+ self._chunk_ids: list[str] | None = None
+
+ # =========================================================================
+ # Private Helper Methods
+ # =========================================================================
+
+ def _is_built(self) -> bool:
+ """Check if the BM25 index has been built.
+
+ This helper method checks whether the retriever has been initialized
+ with a corpus (via build() or load()). Used for validation before
+ operations that require a built index.
+
+ Returns
+ -------
+ bool
+ True if the index is built and ready for retrieval.
+ False if build() or load() has not been called yet.
+
+ """
+ return (
+ self._bm25 is not None
+ and self._chunk_ids is not None
+ and self._tokenized_corpus is not None
+ )
+
+ # =========================================================================
+ # Public Methods
+ # =========================================================================
def build(
self,
corpus: list[str],
chunk_ids: list[str],
) -> None:
- """Build the BM25 index from a corpus.
+ """Build the BM25 index from a corpus of documents.
+
+ This method creates the BM25 index by:
+ 1. Validating input parameters
+ 2. Tokenizing each document in the corpus
+ 3. Building the BM25Okapi index with the tokenized corpus
+ 4. Storing chunk_ids for mapping indices to identifiers
+
+ The build process is idempotent - calling build() multiple times
+ replaces the previous index with a new one.
Args:
----
corpus: List of document texts to index.
- chunk_ids: List of chunk identifiers corresponding to
- each document.
+ Each string is a document that will be tokenized and indexed.
+ Documents are normalized (whitespace, case) during tokenization.
+ chunk_ids: List of unique chunk identifiers.
+ Must have the same length as corpus.
+ Used to identify documents in retrieve() results.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ ValueError: If corpus is empty.
+ ValueError: If corpus and chunk_ids have different lengths.
+ ValueError: If all documents are empty after tokenization.
+
+ Example:
+ -------
+ >>> retriever = BM25Retriever()
+ >>> corpus = [
+ ... "The PMV model predicts thermal sensation.",
+ ... "Thermal comfort depends on air temperature.",
+ ... ]
+ >>> chunk_ids = ["chunk_001", "chunk_002"]
+ >>> retriever.build(corpus, chunk_ids)
+ >>> # Index is now ready for retrieval
+
+ Note:
+ ----
+ - Documents are tokenized (lowercase, punctuation removed, split)
+ - Empty documents after tokenization are preserved in the index
+ but will not match any queries
+ - The rank_bm25 library is loaded on first call to build()
"""
- raise NotImplementedError("build() will be implemented in Step 2.5")
+ # =================================================================
+ # Step 1: Validate corpus is not empty
+ # =================================================================
+ if not corpus:
+ msg = "Cannot build BM25 index with empty corpus"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 2: Validate lengths match
+ # =================================================================
+ if len(corpus) != len(chunk_ids):
+ msg = (
+ f"corpus and chunk_ids length mismatch: "
+ f"{len(corpus)} documents but {len(chunk_ids)} chunk_ids"
+ )
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 3: Tokenize all documents
+ # =================================================================
+ # Each document is normalized and tokenized for BM25 indexing
+ # The tokenized corpus is stored for persistence
+ # =================================================================
+ tokenized_corpus: list[list[str]] = [_tokenize(doc) for doc in corpus]
+
+ # =================================================================
+ # Step 4: Validate that at least some documents have tokens
+ # =================================================================
+ # If ALL documents are empty after tokenization, the index is useless
+ # This catches cases like corpus = [" ", "\t\n", " \t "]
+ # =================================================================
+ if all(len(tokens) == 0 for tokens in tokenized_corpus):
+ msg = (
+ "All documents are empty after tokenization. "
+ "Cannot build BM25 index with no terms."
+ )
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 5: Get the BM25 module (lazy load)
+ # =================================================================
+ # This is the first point where rank_bm25 is actually needed
+ # The module is cached globally after first import
+ # =================================================================
+ bm25_module = _get_bm25_module()
+
+ # =================================================================
+ # Step 6: Build the BM25Okapi index
+ # =================================================================
+ # BM25Okapi is initialized with the tokenized corpus
+ # The k1 and b parameters control scoring behavior
+ # =================================================================
+ self._bm25 = bm25_module.BM25Okapi(
+ corpus=tokenized_corpus,
+ k1=self._k1,
+ b=self._b,
+ )
+
+ # =================================================================
+ # Step 7: Store the tokenized corpus and chunk_ids
+ # =================================================================
+ # These are needed for:
+ # - _tokenized_corpus: persistence (save/load)
+ # - _chunk_ids: mapping indices to chunk identifiers
+ # =================================================================
+ self._tokenized_corpus = tokenized_corpus
+ self._chunk_ids = chunk_ids
def retrieve(
self,
query: str,
top_k: int = 10,
) -> list[tuple[str, float]]:
- """Retrieve relevant documents for a query.
+ """Retrieve the most relevant documents for a query.
+
+ This method searches the BM25 index for documents matching the query
+ and returns the top-k results sorted by relevance score.
+
+ Processing Steps:
+ 1. Validate that the index has been built
+ 2. Validate query and top_k parameters
+ 3. Tokenize the query (same process as document tokenization)
+ 4. Score all documents using BM25
+ 5. Select top-k highest scoring documents
+ 6. Normalize scores to [0, 1] range
+ 7. Return results as (chunk_id, score) tuples
Args:
----
- query: Search query string.
- top_k: Number of results to return.
+ query: The search query string.
+ Will be tokenized using the same process as documents.
+ Must not be empty or whitespace-only.
+ top_k: Maximum number of results to return. Defaults to 10.
+ Must be a positive integer.
+ If top_k exceeds corpus size, all documents are returned.
Returns:
-------
- List of (chunk_id, score) tuples sorted by relevance.
+ List of (chunk_id, score) tuples sorted by score descending.
+ Scores are normalized to [0.0, 1.0] range.
+ Returns at most min(top_k, corpus_size) results.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ RuntimeError: If retrieve() is called before build() or load().
+ ValueError: If query is empty or whitespace-only.
+ ValueError: If top_k is not a positive integer.
+
+ Example:
+ -------
+ >>> results = retriever.retrieve("thermal comfort PMV", top_k=5)
+ >>> for chunk_id, score in results:
+ ... print(f"{chunk_id}: {score:.3f}")
+ chunk_001: 0.923
+ chunk_003: 0.756
+ chunk_002: 0.534
+
+ Note:
+ ----
+ - Query tokenization mirrors document tokenization (lowercase,
+ no punctuation, whitespace split)
+ - If query contains no matching terms, results will have score 0.0
+ - Results are always sorted by score descending (best first)
"""
- raise NotImplementedError("retrieve() will be implemented in Step 2.5")
+ # =================================================================
+ # Step 1: Validate index is built
+ # =================================================================
+ if not self._is_built():
+ msg = "BM25 index not built. Call build() or load() first."
+ raise RuntimeError(msg)
+
+ # =================================================================
+ # Step 2: Validate top_k parameter
+ # =================================================================
+ if not isinstance(top_k, int) or top_k <= 0:
+ msg = f"top_k must be a positive integer, got {top_k}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 3: Validate and tokenize query
+ # =================================================================
+ # Check for empty query before tokenization
+ if not query or not query.strip():
+ msg = "query cannot be empty or whitespace-only"
+ raise ValueError(msg)
+
+ # Tokenize query using same process as documents
+ query_tokens = _tokenize(query)
+
+ # Check for empty query after tokenization
+ # This can happen if query only contains punctuation
+ if not query_tokens:
+ msg = "query is empty after tokenization (no valid terms)"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 4: Get BM25 scores for all documents
+ # =================================================================
+ # get_scores returns a numpy array with score for each document
+ # Type narrowing for mypy (we know _bm25 is not None after _is_built check)
+ assert self._bm25 is not None
+ assert self._chunk_ids is not None
+
+ # Get raw BM25 scores (numpy array)
+ raw_scores = self._bm25.get_scores(query_tokens)
+
+ # =================================================================
+ # Step 5: Create (index, score) pairs and sort by score descending
+ # =================================================================
+ # We need to track indices to map back to chunk_ids
+ # Convert numpy array to list for processing
+ indexed_scores: list[tuple[int, float]] = [
+ (idx, float(score)) for idx, score in enumerate(raw_scores)
+ ]
+
+ # Sort by score descending (highest first)
+ indexed_scores.sort(key=lambda x: x[1], reverse=True)
+
+ # =================================================================
+ # Step 6: Select top-k results
+ # =================================================================
+ # Limit to top_k, but don't exceed corpus size
+ top_k_results = indexed_scores[:top_k]
+
+ # =================================================================
+ # Step 7: Normalize scores to [0, 1] range
+ # =================================================================
+ # Extract scores for normalization
+ scores_only = [score for _, score in top_k_results]
+ normalized_scores = _normalize_scores(scores_only)
+
+ # =================================================================
+ # Step 8: Build final results with chunk_ids
+ # =================================================================
+ # Map indices to chunk_ids and pair with normalized scores
+ results: list[tuple[str, float]] = [
+ (self._chunk_ids[idx], norm_score)
+ for (idx, _), norm_score in zip(
+ top_k_results, normalized_scores, strict=True
+ )
+ ]
+
+ return results
def save(self, path: Path) -> None:
- """Save the index to disk.
+ """Save the BM25 index to disk for later restoration.
+
+ Persists the BM25 index state using pickle. The saved data includes:
+ - k1, b parameters (for rebuilding BM25Okapi)
+ - Tokenized corpus (list of token lists)
+ - Chunk IDs (for result mapping)
+
+ Note that the BM25Okapi object itself is not pickled directly because
+ it may have compatibility issues. Instead, we save the tokenized corpus
+ and rebuild the BM25Okapi index on load().
+
+ Parent directories are created if they don't exist.
Args:
----
path: File path to save the index.
+ Should typically have .pkl extension.
+ Parent directories will be created if needed.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ RuntimeError: If save() is called before build().
+
+ Example:
+ -------
+ >>> retriever = BM25Retriever()
+ >>> retriever.build(corpus, chunk_ids)
+ >>> retriever.save(Path("indexes/bm25_index.pkl"))
+
+ Note:
+ ----
+ - The saved file can be restored with BM25Retriever.load()
+ - Pickle format is used; ensure trusted data sources only
+ - File size depends on corpus size (tokenized text is stored)
"""
- raise NotImplementedError("save() will be implemented in Step 2.5")
+ # =================================================================
+ # Step 1: Validate index is built
+ # =================================================================
+ if not self._is_built():
+ msg = "Cannot save unbuilt BM25 index. Call build() first."
+ raise RuntimeError(msg)
+
+ # =================================================================
+ # Step 2: Create parent directories if needed
+ # =================================================================
+ # This ensures save() works even for nested paths that don't exist
+ # =================================================================
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ # =================================================================
+ # Step 3: Prepare data for persistence
+ # =================================================================
+ # We save all the data needed to rebuild the BM25 index:
+ # - k1, b: BM25 parameters
+ # - tokenized_corpus: Pre-tokenized documents
+ # - chunk_ids: Document identifiers
+ #
+ # The BM25Okapi object is NOT saved directly because:
+ # - It may have numpy arrays that complicate pickling
+ # - Rebuilding from tokenized_corpus is straightforward
+ # =================================================================
+ save_data: dict[str, Any] = {
+ "k1": self._k1,
+ "b": self._b,
+ "tokenized_corpus": self._tokenized_corpus,
+ "chunk_ids": self._chunk_ids,
+ }
+
+ # =================================================================
+ # Step 4: Write to disk using pickle
+ # =================================================================
+ with path.open("wb") as f:
+ pickle.dump(save_data, f, protocol=pickle.HIGHEST_PROTOCOL)
@classmethod
def load(cls, path: Path) -> BM25Retriever:
- """Load an index from disk.
+ """Load a BM25 index from disk.
+
+ Restores a BM25Retriever from a previously saved index file. The
+ BM25Okapi index is rebuilt from the saved tokenized corpus using
+ the saved k1 and b parameters.
Args:
----
path: File path to load the index from.
+ Must be a file created by save().
Returns:
-------
- Loaded BM25Retriever instance.
+ A new BM25Retriever instance with the restored index.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ FileNotFoundError: If the path does not exist.
+
+ Example:
+ -------
+ >>> retriever = BM25Retriever.load(Path("indexes/bm25_index.pkl"))
+ >>> results = retriever.retrieve("thermal comfort", top_k=5)
+
+ Note:
+ ----
+ - The returned retriever is immediately usable for retrieval
+ - The rank_bm25 library is loaded during this operation
+ - Pickle format is used; only load files from trusted sources
"""
- raise NotImplementedError("load() will be implemented in Step 2.5")
+ # =================================================================
+ # Step 1: Validate path exists
+ # =================================================================
+ if not path.exists():
+ msg = f"BM25 index file not found: {path}"
+ raise FileNotFoundError(msg)
+
+ # Step 2: Load saved data from pickle.
+ # Note: Only load from trusted sources as pickle can execute code.
+ with path.open("rb") as f:
+ save_data: dict[str, Any] = pickle.load(f)
+
+ # =================================================================
+ # Step 3: Extract saved values
+ # =================================================================
+ k1: float = save_data["k1"]
+ b: float = save_data["b"]
+ tokenized_corpus: list[list[str]] = save_data["tokenized_corpus"]
+ chunk_ids: list[str] = save_data["chunk_ids"]
+
+ # =================================================================
+ # Step 4: Create new retriever with saved parameters
+ # =================================================================
+ retriever = cls(k1=k1, b=b)
+
+ # =================================================================
+ # Step 5: Get the BM25 module (lazy load)
+ # =================================================================
+ bm25_module = _get_bm25_module()
+
+ # =================================================================
+ # Step 6: Rebuild BM25Okapi index from tokenized corpus
+ # =================================================================
+ # The BM25Okapi index is rebuilt rather than restored because:
+ # - Cleaner serialization (no numpy array issues)
+ # - Ensures consistency with current rank_bm25 version
+ # =================================================================
+ retriever._bm25 = bm25_module.BM25Okapi(
+ corpus=tokenized_corpus,
+ k1=k1,
+ b=b,
+ )
+
+ # =================================================================
+ # Step 7: Restore remaining state
+ # =================================================================
+ retriever._tokenized_corpus = tokenized_corpus
+ retriever._chunk_ids = chunk_ids
+
+ return retriever
diff --git a/src/rag_chatbot/retrieval/chunk_store.py b/src/rag_chatbot/retrieval/chunk_store.py
new file mode 100644
index 0000000000000000000000000000000000000000..e354863d791eb4c61c3bfc96a01bd904aa77e3e0
--- /dev/null
+++ b/src/rag_chatbot/retrieval/chunk_store.py
@@ -0,0 +1,483 @@
+"""Chunk storage for metadata lookup during retrieval.
+
+This module provides the ChunkStore class for loading and accessing
+chunk metadata from JSONL files. The store enables efficient lookup
+of chunk metadata by chunk_id, which is essential for:
+ - Joining FAISS search results with full chunk content
+ - Providing heading hierarchy for context
+ - Source attribution for citations
+
+Storage Format:
+ Chunks are stored in JSONL (JSON Lines) format, where each line
+ is a complete JSON object representing a single chunk. This format
+ supports:
+ - Streaming reads for large files
+ - Append-only updates
+ - Easy debugging (human-readable)
+
+Lazy Loading:
+ Chunk data is loaded on first access to avoid memory overhead
+ until retrieval is actually needed. This is consistent with the
+ project's lazy loading pattern for heavy dependencies.
+
+Example:
+-------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import ChunkStore
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> chunk = store.get("ashrae55_001")
+ >>> if chunk:
+ ... print(chunk.text[:100])
+ ... print(chunk.heading_path)
+
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rag_chatbot.chunking.models import Chunk
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = ["ChunkStore"]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+
+class ChunkStore:
+ """Store for loading and accessing chunks from JSONL files.
+
+ This class provides efficient access to chunk metadata by chunk_id.
+ Chunks are loaded lazily from JSONL files and indexed by their
+ chunk_id for O(1) lookup performance.
+
+ The store is designed to work with the output of the chunking pipeline,
+ where chunks are saved in JSONL format with all metadata required for
+ retrieval and citation.
+
+ Lazy Loading Pattern:
+ - The JSONL file is not read until the first access (get() or __len__)
+ - This avoids loading potentially large chunk collections into memory
+ until they are actually needed
+ - Once loaded, chunks remain in memory for fast repeated access
+
+ Attributes:
+ ----------
+ path : Path
+ Path to the JSONL file containing chunks.
+
+ num_chunks : int
+ Number of chunks in the store (read-only property).
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> # Chunks are loaded on first access
+ >>> chunk = store.get("doc1_chunk_001")
+ >>> if chunk is not None:
+ ... print(f"Found: {chunk.text[:50]}...")
+ ... print(f"Source: {chunk.source}, Page: {chunk.page}")
+ Found: The PMV model predicts thermal sensation...
+ Source: ashrae_55.pdf, Page: 5
+
+ Note:
+ ----
+ The store uses the Chunk model from rag_chatbot.chunking.models
+ for validated, type-safe chunk representation.
+
+ """
+
+ def __init__(self, path: Path) -> None:
+ """Initialize the chunk store with a JSONL file path.
+
+ The file is NOT loaded during initialization to support lazy loading.
+ The actual file reading happens on the first call to get(), __len__(),
+ or any other method that requires chunk data.
+
+ Args:
+ ----
+ path: Path to the JSONL file containing chunk data.
+ The file should contain one JSON object per line,
+ with each object matching the Chunk model schema.
+
+ Raises:
+ ------
+ ValueError: If path is None or not a Path object.
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> # File is not read yet - fast initialization
+ >>> len(store) # File is read on first access
+ 150
+
+ Note:
+ ----
+ The path is validated to be a Path object, but file existence
+ is not checked until the first access. This allows the store
+ to be constructed before the file is created (e.g., in test setup).
+
+ """
+ # Validate path parameter
+ if path is None:
+ msg = "path cannot be None"
+ raise ValueError(msg)
+
+ if not isinstance(path, Path):
+ msg = f"path must be a Path object, got {type(path).__name__}"
+ raise TypeError(msg)
+
+ # Store the path for lazy loading
+ self._path: Path = path
+
+ # Chunk storage indexed by chunk_id
+ # None indicates chunks have not been loaded yet (lazy loading sentinel)
+ self._chunks: dict[str, Chunk] | None = None
+
+ # -------------------------------------------------------------------------
+ # Private Methods
+ # -------------------------------------------------------------------------
+
+ def _ensure_loaded(self) -> None:
+ """Load chunks from the JSONL file if not already loaded.
+
+ This method implements the lazy loading pattern. It reads the JSONL
+ file and parses each line into a Chunk model, indexing by chunk_id.
+
+ The method is idempotent - calling it multiple times after the first
+ load has no effect.
+
+ Processing Steps:
+ 1. Check if chunks are already loaded (skip if so)
+ 2. Verify the file exists
+ 3. Read each line as JSON
+ 4. Parse into Chunk model
+ 5. Index by chunk_id
+
+ Raises:
+ ------
+ FileNotFoundError: If the JSONL file does not exist.
+ json.JSONDecodeError: If a line contains invalid JSON.
+ pydantic.ValidationError: If a line doesn't match Chunk schema.
+
+ Note:
+ ----
+ Parse errors are logged at WARNING level but don't stop processing.
+ This allows partial recovery from corrupted files while alerting
+ to data quality issues.
+
+ """
+ # Skip if already loaded (sentinel check)
+ if self._chunks is not None:
+ return
+
+ # Import Chunk model lazily to avoid circular imports and follow
+ # the project's lazy loading pattern for dependencies
+ from rag_chatbot.chunking.models import Chunk
+
+ # Check file existence before attempting to read
+ if not self._path.exists():
+ msg = f"Chunk file not found: {self._path}"
+ raise FileNotFoundError(msg)
+
+ logger.info("Loading chunks from %s...", self._path)
+
+ # Initialize the chunk dictionary
+ self._chunks = {}
+
+ # Track loading statistics for logging
+ loaded_count = 0
+ error_count = 0
+
+ # Read and parse the JSONL file line by line
+ # Using line-by-line reading for memory efficiency with large files
+ with open(self._path, encoding="utf-8") as f:
+ for line_num, line in enumerate(f, start=1):
+ # Skip empty lines (common at end of file)
+ stripped = line.strip()
+ if not stripped:
+ continue
+
+ try:
+ # Parse JSON from the line
+ data = json.loads(stripped)
+
+ # Validate and create Chunk model
+ # Pydantic handles validation and type coercion
+ chunk = Chunk.model_validate(data)
+
+ # Index by chunk_id for O(1) lookup
+ self._chunks[chunk.chunk_id] = chunk
+ loaded_count += 1
+
+ except json.JSONDecodeError as e:
+ # Log JSON parsing errors but continue processing
+ error_count += 1
+ logger.warning(
+ "Invalid JSON on line %d in %s: %s",
+ line_num,
+ self._path,
+ str(e),
+ )
+ except Exception as e:
+ # Log validation errors but continue processing
+ error_count += 1
+ logger.warning(
+ "Failed to parse chunk on line %d in %s: %s",
+ line_num,
+ self._path,
+ str(e),
+ )
+
+ # Log loading summary
+ if error_count > 0:
+ logger.warning(
+ "Loaded %d chunks from %s with %d errors",
+ loaded_count,
+ self._path,
+ error_count,
+ )
+ else:
+ logger.info(
+ "Loaded %d chunks from %s",
+ loaded_count,
+ self._path,
+ )
+
+ # -------------------------------------------------------------------------
+ # Public Methods
+ # -------------------------------------------------------------------------
+
+ def get(self, chunk_id: str) -> Chunk | None:
+ """Retrieve a chunk by its ID.
+
+ Looks up a chunk in the store by its unique identifier. If the
+ chunks have not been loaded yet, this method triggers lazy loading.
+
+ Args:
+ ----
+ chunk_id: The unique identifier of the chunk to retrieve.
+ This matches the chunk_id field in the Chunk model.
+
+ Returns:
+ -------
+ The Chunk object if found, or None if no chunk with that ID
+ exists in the store.
+
+ Raises:
+ ------
+ FileNotFoundError: If the JSONL file doesn't exist (on first access).
+ ValueError: If chunk_id is None or empty.
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> chunk = store.get("ashrae55_042")
+ >>> if chunk:
+ ... print(f"Text: {chunk.text[:50]}...")
+ ... print(f"Headings: {chunk.heading_path}")
+ Text: The PPD index represents the percentage...
+ Headings: ['Thermal Comfort', 'PMV-PPD Model']
+
+ Note:
+ ----
+ This method returns None rather than raising an exception for
+ missing chunks, enabling graceful handling of race conditions
+ or index inconsistencies during retrieval.
+
+ """
+ # Validate chunk_id parameter
+ if not chunk_id:
+ msg = "chunk_id cannot be None or empty"
+ raise ValueError(msg)
+
+ # Ensure chunks are loaded (lazy loading)
+ self._ensure_loaded()
+
+ # Look up chunk by ID (O(1) dictionary lookup)
+ # _chunks is guaranteed to be set after _ensure_loaded()
+ return self._chunks.get(chunk_id) # type: ignore[union-attr]
+
+ def get_all_chunks(self) -> list[Chunk]:
+ """Get all chunks in the store.
+
+ Returns a list of all chunks currently loaded in the store.
+ If chunks have not been loaded yet, this triggers lazy loading.
+
+ Returns:
+ -------
+ List of all Chunk objects in the store. The order is not
+ guaranteed (dictionary iteration order).
+
+ Raises:
+ ------
+ FileNotFoundError: If the JSONL file doesn't exist (on first access).
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> all_chunks = store.get_all_chunks()
+ >>> print(f"Total chunks: {len(all_chunks)}")
+ Total chunks: 150
+
+ Note:
+ ----
+ This method returns a new list each time, so modifications
+ to the returned list do not affect the store.
+
+ """
+ # Ensure chunks are loaded (lazy loading)
+ self._ensure_loaded()
+
+ # Return a list of all chunk values
+ # _chunks is guaranteed to be set after _ensure_loaded()
+ return list(self._chunks.values()) # type: ignore[union-attr]
+
+ def get_chunk_ids(self) -> list[str]:
+ """Get all chunk IDs in the store.
+
+ Returns a list of all chunk IDs currently in the store.
+ Useful for iteration or validation purposes.
+
+ Returns:
+ -------
+ List of all chunk_id strings in the store.
+
+ Raises:
+ ------
+ FileNotFoundError: If the JSONL file doesn't exist (on first access).
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> ids = store.get_chunk_ids()
+ >>> print(f"First 3 IDs: {ids[:3]}")
+ First 3 IDs: ['doc1_001', 'doc1_002', 'doc1_003']
+
+ """
+ # Ensure chunks are loaded (lazy loading)
+ self._ensure_loaded()
+
+ # Return list of chunk IDs
+ # _chunks is guaranteed to be set after _ensure_loaded()
+ return list(self._chunks.keys()) # type: ignore[union-attr]
+
+ def __len__(self) -> int:
+ """Get the number of chunks in the store.
+
+ Returns the total count of chunks. If chunks have not been
+ loaded yet, this triggers lazy loading.
+
+ Returns:
+ -------
+ Number of chunks in the store.
+
+ Raises:
+ ------
+ FileNotFoundError: If the JSONL file doesn't exist (on first access).
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> print(f"Store contains {len(store)} chunks")
+ Store contains 150 chunks
+
+ """
+ # Ensure chunks are loaded (lazy loading)
+ self._ensure_loaded()
+
+ # Return chunk count
+ # _chunks is guaranteed to be set after _ensure_loaded()
+ return len(self._chunks) # type: ignore[arg-type]
+
+ def __contains__(self, chunk_id: str) -> bool:
+ """Check if a chunk ID exists in the store.
+
+ Enables the 'in' operator for membership testing.
+
+ Args:
+ ----
+ chunk_id: The chunk ID to check for.
+
+ Returns:
+ -------
+ True if the chunk ID exists in the store, False otherwise.
+
+ Raises:
+ ------
+ FileNotFoundError: If the JSONL file doesn't exist (on first access).
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> if "ashrae55_001" in store:
+ ... print("Chunk exists!")
+ Chunk exists!
+
+ """
+ # Ensure chunks are loaded (lazy loading)
+ self._ensure_loaded()
+
+ # Check membership
+ # _chunks is guaranteed to be set after _ensure_loaded()
+ return chunk_id in self._chunks # type: ignore[operator]
+
+ # -------------------------------------------------------------------------
+ # Properties
+ # -------------------------------------------------------------------------
+
+ @property
+ def path(self) -> Path:
+ """Get the path to the JSONL file.
+
+ Returns
+ -------
+ Path to the chunk storage file.
+
+ """
+ return self._path
+
+ @property
+ def num_chunks(self) -> int:
+ """Get the number of chunks in the store.
+
+ This is an alias for __len__() provided for API clarity.
+
+ Returns
+ -------
+ Number of chunks in the store.
+
+ """
+ return len(self)
+
+ @property
+ def is_loaded(self) -> bool:
+ """Check if chunks have been loaded.
+
+ Returns True if chunks have been loaded from the file,
+ False if lazy loading has not yet occurred.
+
+ Returns:
+ -------
+ True if chunks are loaded, False otherwise.
+
+ Example:
+ -------
+ >>> store = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> store.is_loaded
+ False
+ >>> _ = len(store) # Triggers loading
+ >>> store.is_loaded
+ True
+
+ """
+ return self._chunks is not None
diff --git a/src/rag_chatbot/retrieval/dense.py b/src/rag_chatbot/retrieval/dense.py
new file mode 100644
index 0000000000000000000000000000000000000000..de5881b397d9055e0168c279560767dff6f75681
--- /dev/null
+++ b/src/rag_chatbot/retrieval/dense.py
@@ -0,0 +1,549 @@
+"""Dense retrieval using FAISS vector similarity search.
+
+This module provides the DenseRetriever class for semantic retrieval
+using dense embeddings. The retriever combines:
+ - FAISS index for efficient nearest neighbor search
+ - BGE encoder for query embedding
+ - ChunkStore for metadata lookup
+
+The dense retriever is the primary retrieval component, providing
+semantic understanding of queries through embedding similarity.
+
+Design Decisions:
+ - The encoder is lazy loaded to avoid heavy dependencies (torch,
+ sentence-transformers) when using prebuilt indexes
+ - Score normalization maps FAISS inner product scores to [0, 1] range
+ - Missing chunks are handled gracefully with warnings (index/store mismatch)
+
+Lazy Loading:
+ The BGEEncoder is loaded on first retrieve() call if not provided
+ in the constructor. This follows the project convention of lazy-loading
+ heavy dependencies.
+
+Example:
+-------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import DenseRetriever, FAISSIndex, ChunkStore
+ >>> # Load index and chunks
+ >>> index = FAISSIndex.load(Path("data/index.faiss"))
+ >>> chunks = ChunkStore(Path("data/chunks/chunks.jsonl"))
+ >>> # Create retriever
+ >>> retriever = DenseRetriever(faiss_index=index, chunk_store=chunks)
+ >>> # Retrieve
+ >>> results = retriever.retrieve("What is PMV?", top_k=5)
+ >>> for result in results:
+ ... print(f"{result.chunk_id}: {result.score:.3f}")
+
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rag_chatbot.embeddings.encoder import BGEEncoder
+
+ from .chunk_store import ChunkStore
+ from .faiss_index import FAISSIndex
+
+# Import the models directly (lightweight Pydantic models)
+from .models import RetrievalResult, normalize_text
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = ["DenseRetriever"]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+
+class DenseRetriever:
+ """Dense retriever using FAISS for semantic similarity search.
+
+ This class implements the Retriever protocol using dense vector
+ embeddings for semantic search. It combines:
+ - FAISSIndex for efficient nearest neighbor search
+ - ChunkStore for joining search results with chunk metadata
+ - BGEEncoder for encoding queries into embedding vectors
+
+ The retriever uses inner product similarity (from FAISS IndexFlatIP)
+ which works well with normalized embeddings like those produced by
+ BGE models. Scores are normalized to [0, 1] range for consistency
+ with other retrievers.
+
+ Score Normalization:
+ FAISS inner product scores for normalized vectors are in [-1, 1].
+ We normalize to [0, 1] using: normalized = (score + 1) / 2
+ This maps:
+ - Perfect similarity (1.0) -> 1.0
+ - Orthogonal (0.0) -> 0.5
+ - Opposite (-1.0) -> 0.0
+
+ Lazy Loading Pattern:
+ The BGEEncoder is loaded lazily on first retrieve() call if not
+ provided in the constructor. This avoids loading torch and
+ sentence-transformers until actually needed.
+
+ Attributes:
+ ----------
+ faiss_index : FAISSIndex
+ The FAISS index used for similarity search.
+
+ chunk_store : ChunkStore
+ Store for looking up chunk metadata by ID.
+
+ Example:
+ -------
+ >>> # With lazy-loaded encoder
+ >>> retriever = DenseRetriever(faiss_index=index, chunk_store=chunks)
+ >>> results = retriever.retrieve("thermal comfort calculation")
+
+ >>> # With pre-loaded encoder
+ >>> encoder = BGEEncoder()
+ >>> retriever = DenseRetriever(
+ ... faiss_index=index,
+ ... chunk_store=chunks,
+ ... encoder=encoder
+ ... )
+
+ Note:
+ ----
+ The retriever implements the Retriever protocol defined in
+ rag_chatbot.retrieval.models, enabling use with HybridRetriever
+ and dependency injection in tests.
+
+ """
+
+ def __init__(
+ self,
+ faiss_index: FAISSIndex,
+ chunk_store: ChunkStore,
+ encoder: BGEEncoder | None = None,
+ ) -> None:
+ """Initialize the dense retriever.
+
+ Creates a DenseRetriever with the specified FAISS index and chunk
+ store. The encoder can be provided or will be lazy loaded on first
+ retrieve() call.
+
+ Args:
+ ----
+ faiss_index: FAISS index for vector similarity search.
+ Must be a trained/loaded FAISSIndex instance.
+ chunk_store: Store for chunk metadata lookup.
+ Must contain chunks matching the indexed chunk_ids.
+ encoder: Optional BGE encoder for query embedding.
+ If None, a BGEEncoder will be created lazily on first
+ retrieve() call. Providing an encoder is useful for:
+ - Sharing an encoder across multiple retrievers
+ - Using a custom model or configuration
+ - Testing with mock encoders
+
+ Raises:
+ ------
+ ValueError: If faiss_index or chunk_store is None.
+
+ Example:
+ -------
+ >>> # Basic initialization (lazy encoder)
+ >>> retriever = DenseRetriever(
+ ... faiss_index=index,
+ ... chunk_store=chunks
+ ... )
+
+ >>> # With explicit encoder
+ >>> encoder = BGEEncoder(device="cpu")
+ >>> retriever = DenseRetriever(
+ ... faiss_index=index,
+ ... chunk_store=chunks,
+ ... encoder=encoder
+ ... )
+
+ Note:
+ ----
+ The faiss_index should have been built with embeddings from
+ the same model as the encoder (default: bge-small-en-v1.5).
+
+ """
+ # =================================================================
+ # Validate required parameters
+ # =================================================================
+ if faiss_index is None:
+ msg = "faiss_index cannot be None"
+ raise ValueError(msg)
+
+ if chunk_store is None:
+ msg = "chunk_store cannot be None"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Store dependencies
+ # =================================================================
+ self._faiss_index: FAISSIndex = faiss_index
+ self._chunk_store: ChunkStore = chunk_store
+
+ # =================================================================
+ # Encoder is optional (lazy loaded if not provided)
+ # Using None as sentinel for lazy initialization
+ # =================================================================
+ self._encoder: BGEEncoder | None = encoder
+
+ logger.debug(
+ "Initialized DenseRetriever with %d indexed vectors",
+ self._faiss_index.num_vectors,
+ )
+
+ # -------------------------------------------------------------------------
+ # Private Methods
+ # -------------------------------------------------------------------------
+
+ def _ensure_encoder_loaded(self) -> BGEEncoder:
+ """Load the encoder if not already loaded.
+
+ This method implements lazy loading for the BGEEncoder. If an
+ encoder was provided in the constructor, it is returned directly.
+ Otherwise, a new BGEEncoder is created and cached.
+
+ Returns:
+ -------
+ The BGEEncoder instance to use for query encoding.
+
+ Note:
+ ----
+ The encoder is cached after first creation, so subsequent
+ calls return the same instance without reloading.
+
+ """
+ # Return existing encoder if available
+ if self._encoder is not None:
+ return self._encoder
+
+ # =================================================================
+ # Lazy import and create encoder
+ # =================================================================
+ # Import BGEEncoder here to avoid loading torch and
+ # sentence-transformers at module import time
+ # =================================================================
+ logger.debug("Lazy loading BGEEncoder for query encoding")
+
+ from rag_chatbot.embeddings import BGEEncoder
+
+ # Create and cache the encoder
+ self._encoder = BGEEncoder()
+
+ return self._encoder
+
+ def _normalize_score(self, raw_score: float) -> float:
+ """Normalize a FAISS inner product score to [0, 1] range.
+
+ FAISS IndexFlatIP returns inner product similarity scores. For
+ normalized embeddings (like those from BGE models), these scores
+ are in the range [-1, 1], representing the cosine similarity.
+
+ This method normalizes to [0, 1] for consistency with other
+ retrievers and the RetrievalResult model's score constraints.
+
+ Normalization Formula:
+ normalized = (raw_score + 1) / 2
+
+ Score Mapping:
+ - raw_score = 1.0 (perfect match) -> 1.0
+ - raw_score = 0.0 (orthogonal) -> 0.5
+ - raw_score = -1.0 (opposite) -> 0.0
+
+ Args:
+ ----
+ raw_score: Raw inner product score from FAISS search.
+ Expected range is [-1, 1] for normalized embeddings.
+
+ Returns:
+ -------
+ Normalized score in [0, 1] range, clamped to ensure valid range.
+
+ Example:
+ -------
+ >>> retriever._normalize_score(0.85)
+ 0.925
+ >>> retriever._normalize_score(-0.5)
+ 0.25
+
+ Note:
+ ----
+ The result is clamped to [0, 1] to handle any edge cases
+ where scores might fall slightly outside [-1, 1] due to
+ numerical precision issues.
+
+ """
+ # Normalize from [-1, 1] to [0, 1]
+ # Formula: (x + 1) / 2 maps -1 -> 0, 0 -> 0.5, 1 -> 1
+ normalized = (raw_score + 1.0) / 2.0
+
+ # Clamp to [0, 1] to ensure valid range
+ # This handles numerical precision edge cases
+ return max(0.0, min(1.0, normalized))
+
+ # -------------------------------------------------------------------------
+ # Public Methods (Retriever Protocol)
+ # -------------------------------------------------------------------------
+
+ def retrieve(self, query: str, top_k: int = 6) -> list[RetrievalResult]:
+ """Retrieve relevant chunks for a given query.
+
+ Encodes the query using BGE embeddings and searches the FAISS
+ index for the most similar chunks. Results include full chunk
+ metadata from the chunk store, with scores normalized to [0, 1].
+
+ Processing Steps:
+ 1. Validate query and top_k parameters
+ 2. Handle empty index case (return empty list)
+ 3. Normalize query text to fix OCR/extraction artifacts
+ 4. Encode query with BGEEncoder (lazy loaded if needed)
+ 5. Search FAISS index for top_k nearest neighbors
+ 6. Join results with chunk metadata from store
+ 7. Normalize scores to [0, 1] range
+ 8. Return sorted RetrievalResult objects
+
+ Args:
+ ----
+ query: The search query string. Will be normalized before
+ encoding to handle common text issues.
+ top_k: Maximum number of results to return. Defaults to 6.
+ Must be a positive integer. If the index contains fewer
+ vectors, all vectors are returned.
+
+ Returns:
+ -------
+ List of RetrievalResult objects sorted by score in descending
+ order (highest relevance first). Each result contains:
+ - chunk_id: Unique identifier of the chunk
+ - text: Full text content of the chunk
+ - score: Normalized relevance score [0, 1]
+ - heading_path: Hierarchical heading context
+ - source: Source document name
+ - page: Page number in source document
+
+ Returns empty list if:
+ - The index is empty (no vectors)
+ - The query is empty after normalization
+
+ Raises:
+ ------
+ ValueError: If query is empty or top_k is not positive.
+ RuntimeError: If FAISS search fails.
+
+ Example:
+ -------
+ >>> results = retriever.retrieve("What is PMV?", top_k=5)
+ >>> for result in results:
+ ... print(f"[{result.score:.3f}] {result.chunk_id}")
+ ... print(f" Source: {result.source}, Page: {result.page}")
+ ... print(f" Text: {result.text[:80]}...")
+ [0.923] ashrae55_042
+ Source: ashrae_55.pdf, Page: 15
+ Text: The PMV (Predicted Mean Vote) is an index that predicts the mean...
+ [0.891] iso7730_015
+ Source: iso_7730.pdf, Page: 8
+ Text: PMV is calculated using the following equation...
+
+ Note:
+ ----
+ Chunks that exist in the FAISS index but not in the chunk
+ store are skipped with a warning log. This can happen if
+ the index and chunks become out of sync.
+
+ """
+ # =================================================================
+ # Step 1: Validate input parameters
+ # =================================================================
+ if not query:
+ msg = "query cannot be empty"
+ raise ValueError(msg)
+
+ if not isinstance(top_k, int) or top_k <= 0:
+ msg = f"top_k must be a positive integer, got {top_k}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 2: Handle empty index case
+ # =================================================================
+ # Return empty list if there are no vectors to search
+ # =================================================================
+ if self._faiss_index.num_vectors == 0:
+ logger.debug("FAISS index is empty, returning no results")
+ return []
+
+ # =================================================================
+ # Step 3: Normalize query text
+ # =================================================================
+ # Apply text normalization to fix common issues from user input
+ # or copied text (extra spaces, jumbled words, etc.)
+ # =================================================================
+ normalized_query = normalize_text(query)
+
+ # Handle case where normalization results in empty string
+ if not normalized_query:
+ logger.warning("Query is empty after normalization: %r", query)
+ return []
+
+ logger.debug(
+ "Processing query: %r (normalized: %r)",
+ query,
+ normalized_query,
+ )
+
+ # =================================================================
+ # Step 4: Encode query with BGEEncoder
+ # =================================================================
+ # Lazy load encoder if not already available
+ # The encoder handles text normalization internally as well,
+ # but we pre-normalize for logging purposes
+ # =================================================================
+ encoder = self._ensure_encoder_loaded()
+
+ # Encode the query (returns shape (1, embedding_dim))
+ # We pass a list with single query and take first embedding
+ query_embedding = encoder.encode([normalized_query])[0]
+
+ logger.debug(
+ "Encoded query to embedding with shape %s",
+ query_embedding.shape,
+ )
+
+ # =================================================================
+ # Step 5: Search FAISS index
+ # =================================================================
+ # The search returns list of (chunk_id, score) tuples
+ # sorted by score descending
+ # =================================================================
+ search_results = self._faiss_index.search(
+ query_embedding=query_embedding,
+ top_k=top_k,
+ )
+
+ logger.debug(
+ "FAISS search returned %d results",
+ len(search_results),
+ )
+
+ # =================================================================
+ # Step 6: Join with chunk metadata and build results
+ # =================================================================
+ # Look up each chunk_id in the chunk store to get full metadata
+ # Skip missing chunks with a warning
+ # =================================================================
+ results: list[RetrievalResult] = []
+
+ for chunk_id, raw_score in search_results:
+ # Look up chunk metadata
+ chunk = self._chunk_store.get(chunk_id)
+
+ if chunk is None:
+ # Log warning for missing chunk but continue processing
+ # This can happen if index and chunks are out of sync
+ logger.warning(
+ "Chunk %r found in FAISS index but not in chunk store, skipping",
+ chunk_id,
+ )
+ continue
+
+ # =================================================================
+ # Step 7: Normalize score to [0, 1] range
+ # =================================================================
+ normalized_score = self._normalize_score(raw_score)
+
+ # =================================================================
+ # Step 8: Create RetrievalResult
+ # =================================================================
+ # The RetrievalResult model validates all fields including
+ # score range constraints
+ # =================================================================
+ try:
+ result = RetrievalResult(
+ chunk_id=chunk_id,
+ text=chunk.text,
+ score=normalized_score,
+ heading_path=chunk.heading_path,
+ source=chunk.source,
+ page=chunk.page,
+ )
+ results.append(result)
+ except Exception as e:
+ # Log validation errors but continue with other results
+ logger.warning(
+ "Failed to create RetrievalResult for chunk %r: %s",
+ chunk_id,
+ str(e),
+ )
+ continue
+
+ # Results are already sorted by score from FAISS search
+ # Truncate query for logging to avoid excessively long log lines
+ max_query_log_length = 50
+ if len(normalized_query) > max_query_log_length:
+ query_preview = normalized_query[:max_query_log_length] + "..."
+ else:
+ query_preview = normalized_query
+
+ logger.info(
+ "Retrieved %d results for query: %r",
+ len(results),
+ query_preview,
+ )
+
+ return results
+
+ # -------------------------------------------------------------------------
+ # Properties
+ # -------------------------------------------------------------------------
+
+ @property
+ def faiss_index(self) -> FAISSIndex:
+ """Get the FAISS index used for search.
+
+ Returns
+ -------
+ The FAISSIndex instance.
+
+ """
+ return self._faiss_index
+
+ @property
+ def chunk_store(self) -> ChunkStore:
+ """Get the chunk store used for metadata lookup.
+
+ Returns
+ -------
+ The ChunkStore instance.
+
+ """
+ return self._chunk_store
+
+ @property
+ def num_indexed(self) -> int:
+ """Get the number of vectors in the FAISS index.
+
+ Returns
+ -------
+ Number of indexed vectors.
+
+ """
+ return self._faiss_index.num_vectors
+
+ @property
+ def encoder_loaded(self) -> bool:
+ """Check if the encoder has been loaded.
+
+ Returns True if the encoder is available (either provided
+ in constructor or lazy loaded), False if still pending
+ lazy initialization.
+
+ Returns
+ -------
+ True if encoder is loaded, False otherwise.
+
+ """
+ return self._encoder is not None
diff --git a/src/rag_chatbot/retrieval/factory.py b/src/rag_chatbot/retrieval/factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..91af3030e5731ea66c5d7868b1f1c325c5a5d262
--- /dev/null
+++ b/src/rag_chatbot/retrieval/factory.py
@@ -0,0 +1,584 @@
+"""Retriever factory module for unified retriever instantiation.
+
+This module provides a factory function and wrapper class for creating and
+configuring retrievers based on application settings. It serves as the single
+entry point for obtaining the default retriever in the RAG pipeline.
+
+The factory pattern provides several benefits:
+ - Centralized retriever configuration based on Settings
+ - Consistent retriever instantiation across the application
+ - Easy swapping between retriever implementations
+ - Optional reranker wrapping without modifying retriever code
+ - Lazy loading of heavy dependencies (torch, faiss, sentence-transformers)
+
+Components:
+ - get_default_retriever: Factory function to create configured retriever
+ - RetrieverWithReranker: Wrapper class adding optional reranking capability
+
+Configuration Sources:
+ The factory reads from Settings (src/rag_chatbot/config/settings.py):
+ - use_hybrid: bool = True (default) - Use HybridRetriever vs DenseRetriever
+ - use_reranker: bool = False (default) - Enable cross-encoder reranking
+ - top_k: int = 6 (default) - Number of chunks to retrieve (used as default)
+
+Lazy Loading:
+ All heavy dependencies are loaded on first use:
+ - HybridRetriever/DenseRetriever: Loaded when get_default_retriever() is called
+ - Reranker: Loaded only if use_reranker=True and first rerank() call
+ - FAISS, torch, sentence-transformers: Loaded transitively through retrievers
+
+Example:
+-------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import get_default_retriever
+ >>> # Get default retriever based on Settings
+ >>> retriever = get_default_retriever(Path("data/index"))
+ >>> # Use the retriever
+ >>> results = retriever.retrieve("What is PMV?", top_k=5)
+ >>> for result in results:
+ ... print(f"[{result.score:.3f}] {result.chunk_id}")
+
+See Also:
+--------
+ - HybridRetriever: Combines dense + BM25 with RRF fusion
+ - DenseRetriever: Dense retrieval using FAISS and BGE embeddings
+ - Reranker: Cross-encoder reranking for improved relevance
+ - Settings: Application configuration from environment variables
+
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# Heavy dependencies like retrievers and rerankers are NOT imported at runtime
+# to ensure fast module loading and minimal memory usage.
+# =============================================================================
+
+if TYPE_CHECKING:
+ from rag_chatbot.config.settings import Settings
+
+ from .dense import DenseRetriever
+ from .hybrid import HybridRetriever
+ from .reranker import Reranker
+
+# Import the models directly (lightweight Pydantic models - no lazy loading needed)
+from .models import RetrievalResult
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = [
+ "get_default_retriever",
+ "RetrieverWithReranker",
+]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# RetrieverWithReranker Wrapper Class
+# =============================================================================
+
+
+class RetrieverWithReranker:
+ """Wrapper class that adds optional reranking to any retriever.
+
+ This class implements the Retriever protocol and wraps an existing retriever
+ with optional cross-encoder reranking. The wrapper allows transparent
+ addition of reranking capability without modifying the underlying retriever.
+
+ The reranker is loaded lazily on first retrieve() call if use_reranker=True.
+ When use_reranker=False, the wrapper simply delegates to the underlying
+ retriever without any overhead.
+
+ Design Decisions:
+ - Implements Retriever protocol for drop-in compatibility
+ - Lazy loads reranker only when needed (avoids torch/sentence-transformers)
+ - Over-fetches from base retriever to provide candidates for reranking
+ - Preserves original results if reranker is disabled
+
+ Over-fetching Strategy:
+ When reranking is enabled, we fetch more results from the base retriever
+ than requested (fetch_k = top_k * 3) to ensure the reranker has enough
+ candidates to choose from. The reranker then selects the final top_k
+ results from this larger candidate pool.
+
+ Attributes:
+ ----------
+ retriever : HybridRetriever | DenseRetriever
+ The underlying retriever to wrap.
+
+ use_reranker : bool
+ Whether to apply reranking after retrieval.
+
+ Example:
+ -------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import HybridRetriever, RetrieverWithReranker
+ >>> # Load base retriever
+ >>> base = HybridRetriever.from_path(Path("data/index"))
+ >>> # Wrap with reranker
+ >>> retriever = RetrieverWithReranker(
+ ... retriever=base,
+ ... use_reranker=True
+ ... )
+ >>> # Results are now reranked
+ >>> results = retriever.retrieve("thermal comfort", top_k=5)
+
+ Note:
+ ----
+ This class implements the Retriever protocol defined in
+ rag_chatbot.retrieval.models, enabling use with dependency injection
+ and testing frameworks.
+
+ """
+
+ def __init__(
+ self,
+ retriever: HybridRetriever | DenseRetriever,
+ use_reranker: bool = False,
+ ) -> None:
+ """Initialize the retriever wrapper with optional reranking.
+
+ Creates a RetrieverWithReranker that wraps the given retriever and
+ optionally applies cross-encoder reranking to improve result relevance.
+
+ Args:
+ ----
+ retriever: The base retriever to wrap. Can be either HybridRetriever
+ or DenseRetriever. Must implement the Retriever protocol.
+ use_reranker: Whether to enable cross-encoder reranking. Defaults to
+ False. When True, the Reranker is lazy loaded on first retrieve()
+ call and results are reranked for improved relevance.
+
+ Raises:
+ ------
+ ValueError: If retriever is None.
+
+ Example:
+ -------
+ >>> # Without reranking (pass-through)
+ >>> wrapper = RetrieverWithReranker(retriever=base, use_reranker=False)
+
+ >>> # With reranking (adds ~200-500ms latency)
+ >>> wrapper = RetrieverWithReranker(retriever=base, use_reranker=True)
+
+ Note:
+ ----
+ The Reranker is NOT loaded during __init__. It is lazy loaded on
+ the first retrieve() call when use_reranker=True. This ensures fast
+ instantiation and minimal memory usage until reranking is needed.
+
+ """
+ # =================================================================
+ # Validate required parameters
+ # =================================================================
+ if retriever is None:
+ msg = "retriever cannot be None"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Store configuration
+ # =================================================================
+ self._retriever: HybridRetriever | DenseRetriever = retriever
+ self._use_reranker: bool = use_reranker
+
+ # =================================================================
+ # Reranker placeholder (lazy loaded)
+ # =================================================================
+ # Using None as sentinel for lazy initialization
+ # The actual Reranker is loaded on first retrieve() call if needed
+ # =================================================================
+ self._reranker: Reranker | None = None
+
+ logger.debug(
+ "Initialized RetrieverWithReranker: use_reranker=%s",
+ self._use_reranker,
+ )
+
+ # -------------------------------------------------------------------------
+ # Private Methods
+ # -------------------------------------------------------------------------
+
+ def _ensure_reranker_loaded(self) -> Reranker:
+ """Load the reranker if not already loaded.
+
+ This method implements lazy loading for the Reranker. If the reranker
+ was already loaded, it returns the cached instance. Otherwise, it
+ imports and creates a new Reranker instance.
+
+ Returns:
+ -------
+ The Reranker instance to use for reranking.
+
+ Note:
+ ----
+ The reranker is cached after first creation, so subsequent calls
+ return the same instance without reloading.
+
+ This method is only called when use_reranker=True and retrieve()
+ is invoked.
+
+ """
+ # Return existing reranker if available
+ if self._reranker is not None:
+ return self._reranker
+
+ # =================================================================
+ # Lazy import and create reranker
+ # =================================================================
+ # Import Reranker here to avoid loading torch and sentence-transformers
+ # at module import time. The Reranker itself also lazy loads its model.
+ # =================================================================
+ logger.debug("Lazy loading Reranker for result reranking")
+
+ from .reranker import Reranker
+
+ # Create and cache the reranker
+ # The reranker's cross-encoder model is also lazy loaded on first rerank()
+ self._reranker = Reranker()
+
+ return self._reranker
+
+ # -------------------------------------------------------------------------
+ # Public Methods (Retriever Protocol)
+ # -------------------------------------------------------------------------
+
+ def retrieve(self, query: str, top_k: int = 6) -> list[RetrievalResult]:
+ """Retrieve relevant chunks for a given query with optional reranking.
+
+ When use_reranker=False (default):
+ Simply delegates to the underlying retriever's retrieve() method.
+ No additional overhead is added.
+
+ When use_reranker=True:
+ 1. Over-fetches from base retriever (fetch_k = top_k * 3)
+ 2. Loads reranker lazily if not already loaded
+ 3. Applies cross-encoder reranking to the candidate set
+ 4. Returns top_k results sorted by cross-encoder score
+
+ Args:
+ ----
+ query: The search query string. Should be a natural language
+ question or keyword phrase.
+ top_k: Maximum number of results to return. Defaults to 6.
+ Must be a positive integer.
+
+ Returns:
+ -------
+ List of RetrievalResult objects sorted by relevance score in
+ descending order (highest relevance first). Each result contains:
+ - chunk_id: Unique identifier of the chunk
+ - text: Full text content of the chunk
+ - score: Relevance score [0, 1] (cross-encoder score if reranked)
+ - heading_path: Hierarchical heading context
+ - source: Source document name
+ - page: Page number in source document
+
+ Raises:
+ ------
+ ValueError: If query is empty or top_k is not positive.
+ RuntimeError: If retrieval or reranking fails.
+
+ Example:
+ -------
+ >>> results = retriever.retrieve("What is PMV?", top_k=5)
+ >>> for result in results:
+ ... print(f"[{result.score:.3f}] {result.chunk_id}")
+
+ Note:
+ ----
+ When reranking is enabled, the returned scores are cross-encoder
+ scores (not the original retrieval scores). Cross-encoder scores
+ provide more accurate relevance judgments at the cost of additional
+ latency (~200-500ms depending on number of candidates).
+
+ """
+ # =================================================================
+ # Step 1: Check if reranking is disabled
+ # =================================================================
+ # When reranker is disabled, simply delegate to underlying retriever
+ # This path has zero overhead
+ # =================================================================
+ if not self._use_reranker:
+ logger.debug("Reranker disabled, delegating to base retriever")
+ return self._retriever.retrieve(query, top_k)
+
+ # =================================================================
+ # Step 2: Over-fetch candidates for reranking
+ # =================================================================
+ # We fetch more candidates than requested because the reranker may
+ # re-order results significantly. Over-fetching by 3x is a common
+ # practice that balances candidate diversity vs latency.
+ #
+ # Example: If top_k=5, we fetch 15 candidates from base retriever,
+ # rerank all 15, then return only the top 5.
+ # =================================================================
+ fetch_k = top_k * 3 # Over-fetch for reranking
+
+ logger.debug(
+ "Fetching %d candidates for reranking (top_k=%d)",
+ fetch_k,
+ top_k,
+ )
+
+ # Get candidates from base retriever
+ candidates = self._retriever.retrieve(query, fetch_k)
+
+ # =================================================================
+ # Step 3: Handle edge cases
+ # =================================================================
+ # If no candidates were returned, nothing to rerank
+ # =================================================================
+ if not candidates:
+ logger.debug("No candidates returned from base retriever")
+ return []
+
+ # If we got fewer candidates than requested top_k, reranking still
+ # provides value by computing more accurate scores
+ logger.debug(
+ "Retrieved %d candidates, applying reranking",
+ len(candidates),
+ )
+
+ # =================================================================
+ # Step 4: Load reranker and apply reranking
+ # =================================================================
+ # Lazy load the reranker on first use
+ # The reranker's model is also lazy loaded internally
+ # =================================================================
+ reranker = self._ensure_reranker_loaded()
+
+ # Rerank candidates and get top_k results
+ # The reranker replaces retrieval scores with cross-encoder scores
+ reranked_results = reranker.rerank(query, candidates, top_k=top_k)
+
+ logger.debug(
+ "Reranking complete, returning %d results",
+ len(reranked_results),
+ )
+
+ return reranked_results
+
+ # -------------------------------------------------------------------------
+ # Properties
+ # -------------------------------------------------------------------------
+
+ @property
+ def retriever(self) -> HybridRetriever | DenseRetriever:
+ """Get the underlying retriever.
+
+ Returns
+ -------
+ The base retriever instance being wrapped.
+
+ """
+ return self._retriever
+
+ @property
+ def use_reranker(self) -> bool:
+ """Check if reranking is enabled.
+
+ Returns
+ -------
+ True if reranking is enabled, False otherwise.
+
+ """
+ return self._use_reranker
+
+ @property
+ def reranker_loaded(self) -> bool:
+ """Check if the reranker has been loaded.
+
+ Returns True if the reranker has been initialized (through retrieve()
+ call with use_reranker=True), False if still pending lazy initialization.
+
+ Returns
+ -------
+ True if reranker is loaded, False otherwise.
+
+ """
+ return self._reranker is not None
+
+
+# =============================================================================
+# Factory Function
+# =============================================================================
+
+
+def get_default_retriever(
+ index_path: Path,
+ settings: Settings | None = None,
+) -> RetrieverWithReranker:
+ """Get the default retriever based on application settings.
+
+ This factory function creates and configures a retriever based on the
+ application settings. It provides a single entry point for obtaining
+ the default retriever throughout the application.
+
+ The function:
+ 1. Loads Settings from environment if not provided
+ 2. Creates either HybridRetriever or DenseRetriever based on use_hybrid
+ 3. Wraps with RetrieverWithReranker if use_reranker is configured
+ 4. Returns a fully configured retriever ready for use
+
+ Configuration from Settings:
+ - use_hybrid (default: True): If True, creates HybridRetriever which
+ combines dense (FAISS) and sparse (BM25) retrieval using RRF fusion.
+ If False, creates DenseRetriever for semantic-only retrieval.
+ - use_reranker (default: False): If True, wraps the retriever with
+ cross-encoder reranking for improved relevance (adds ~200-500ms).
+
+ Lazy Loading:
+ All heavy dependencies are loaded lazily:
+ - HybridRetriever/DenseRetriever: Loaded when this function is called
+ - FAISS index, BM25 index: Loaded by the retriever's from_path()
+ - BGE encoder: Loaded on first retrieve() call
+ - Reranker model: Loaded on first rerank() call (if enabled)
+
+ Args:
+ ----
+ index_path: Path to the directory containing index files.
+ Expected structure:
+ index_path/
+ faiss.index - FAISS index binary
+ faiss.index.json - FAISS chunk ID mapping
+ bm25.pkl - BM25 pickled index
+ chunks.jsonl - Chunk metadata
+ settings: Optional Settings instance. If None, creates a new Settings
+ instance which loads configuration from environment variables.
+ Providing settings is useful for:
+ - Testing with custom configuration
+ - Sharing settings across multiple components
+ - Overriding defaults programmatically
+
+ Returns:
+ -------
+ A RetrieverWithReranker instance configured based on settings.
+ The wrapper implements the Retriever protocol and can be used
+ anywhere a retriever is expected.
+
+ Raises:
+ ------
+ FileNotFoundError: If required index files are missing from index_path.
+ ValueError: If index_path is not a valid directory.
+
+ Example:
+ -------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import get_default_retriever
+ >>> # Use default settings from environment
+ >>> retriever = get_default_retriever(Path("data/index"))
+ >>> results = retriever.retrieve("thermal comfort PMV model", top_k=5)
+
+ >>> # Use custom settings
+ >>> from rag_chatbot.config import Settings
+ >>> settings = Settings()
+ >>> retriever = get_default_retriever(Path("data/index"), settings=settings)
+
+ Note:
+ ----
+ The returned retriever always wraps with RetrieverWithReranker for
+ consistency, even when use_reranker=False. When disabled, the wrapper
+ simply delegates to the base retriever with zero overhead.
+
+ See Also:
+ --------
+ - HybridRetriever: Combined dense + BM25 retrieval
+ - DenseRetriever: Semantic-only retrieval
+ - RetrieverWithReranker: Optional reranking wrapper
+ - Settings: Application configuration
+
+ """
+ # =================================================================
+ # Step 1: Load settings if not provided
+ # =================================================================
+ # Lazy import Settings to avoid loading pydantic_settings at module
+ # import time. Settings itself lazy loads pydantic_settings.
+ # =================================================================
+ if settings is None:
+ logger.debug("No settings provided, loading from environment")
+
+ from rag_chatbot.config.settings import Settings
+
+ settings = Settings()
+
+ # Log the configuration being used
+ logger.info(
+ "Creating retriever: use_hybrid=%s, use_reranker=%s, top_k=%d",
+ settings.use_hybrid,
+ settings.use_reranker,
+ settings.top_k,
+ )
+
+ # =================================================================
+ # Step 2: Convert index_path to Path if needed
+ # =================================================================
+ index_path = Path(index_path)
+
+ # =================================================================
+ # Step 3: Create base retriever based on use_hybrid setting
+ # =================================================================
+ # We always use HybridRetriever but configure its use_hybrid flag
+ # based on settings. This allows using either hybrid or dense-only
+ # mode from the same retriever class.
+ #
+ # HybridRetriever with use_hybrid=False:
+ # - Delegates to DenseRetriever internally
+ # - BM25 is loaded but not used (slight memory overhead)
+ # - Provides consistent interface
+ #
+ # Alternative: Create DenseRetriever directly when use_hybrid=False
+ # - Saves BM25 loading overhead
+ # - Requires managing two code paths
+ #
+ # We choose the HybridRetriever approach for simplicity and consistency.
+ # The BM25 memory overhead is minimal (~few MB) compared to FAISS/encoder.
+ # =================================================================
+ logger.debug("Loading retriever from path: %s", index_path)
+
+ # Import HybridRetriever lazily to avoid loading torch/faiss at module import
+ from .hybrid import HybridRetriever
+
+ # Create base retriever using HybridRetriever's from_path method
+ # The use_hybrid flag controls whether RRF fusion is applied
+ base_retriever = HybridRetriever.from_path(
+ index_path=index_path,
+ use_hybrid=settings.use_hybrid,
+ )
+
+ logger.debug(
+ "Created base retriever: %s (use_hybrid=%s)",
+ type(base_retriever).__name__,
+ settings.use_hybrid,
+ )
+
+ # =================================================================
+ # Step 4: Wrap with RetrieverWithReranker
+ # =================================================================
+ # We always wrap with RetrieverWithReranker for consistency.
+ # When use_reranker=False, the wrapper is a no-op pass-through.
+ # =================================================================
+ retriever = RetrieverWithReranker(
+ retriever=base_retriever,
+ use_reranker=settings.use_reranker,
+ )
+
+ logger.info(
+ "Retriever ready: %s with%s reranking",
+ "Hybrid" if settings.use_hybrid else "Dense-only",
+ "" if settings.use_reranker else "out",
+ )
+
+ return retriever
diff --git a/src/rag_chatbot/retrieval/faiss_index.py b/src/rag_chatbot/retrieval/faiss_index.py
index 21adebad6ab2a30eff4d754fc02431278710f68a..c105593697969e61c7a65b13e20661d490de7102 100644
--- a/src/rag_chatbot/retrieval/faiss_index.py
+++ b/src/rag_chatbot/retrieval/faiss_index.py
@@ -2,99 +2,309 @@
This module provides the FAISSIndex class for building and querying
FAISS indexes for dense retrieval. The index supports:
- - Efficient nearest neighbor search
- - Index persistence and loading
- - GPU acceleration when available
+ - Efficient nearest neighbor search using inner product similarity
+ - Index persistence and loading with chunk ID mapping
+ - Memory-efficient CPU deployment with float32 vectors
-Lazy Loading:
- faiss is loaded on first use to avoid import overhead when
- dense retrieval is not needed.
+Storage Format:
+ The index is stored in two files:
+ - {path}: FAISS index binary file
+ - {path}.json: JSON file with chunk_ids and embedding_dim
-Note:
-----
- This is a placeholder that will be fully implemented in Step 2.5.
+Lazy Loading:
+ FAISS is loaded on first use to avoid import overhead when dense
+ retrieval is not needed. This follows the project convention of
+ lazy-loading heavy dependencies (torch, faiss, sentence-transformers).
+
+Example:
+-------
+ >>> from pathlib import Path
+ >>> import numpy as np
+ >>> from rag_chatbot.retrieval import FAISSIndex
+ >>> # Build index
+ >>> index = FAISSIndex(embedding_dim=384)
+ >>> embeddings = np.random.randn(100, 384).astype(np.float32)
+ >>> chunk_ids = [f"chunk_{i:03d}" for i in range(100)]
+ >>> index.build(embeddings, chunk_ids)
+ >>> # Search
+ >>> query = np.random.randn(384).astype(np.float32)
+ >>> results = index.search(query, top_k=5)
+ >>> # Save and load
+ >>> index.save(Path("data/index.faiss"))
+ >>> loaded_index = FAISSIndex.load(Path("data/index.faiss"))
"""
from __future__ import annotations
+import json
+import logging
+import time
+from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
- from pathlib import Path
+ pass
# =============================================================================
# Module Exports
# =============================================================================
__all__: list[str] = ["FAISSIndex"]
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
-class FAISSIndex: # pragma: no cover
+class FAISSIndex:
"""FAISS-based dense vector index for similarity search.
- This class provides methods for building and querying a FAISS
- index for efficient nearest neighbor search on embedding vectors.
- The index supports:
- - Building from embedding arrays
- - Saving and loading from disk
- - Batch query processing
- - Configurable distance metrics
+ This class provides methods for building and querying a FAISS index
+ for efficient nearest neighbor search on embedding vectors. The index
+ uses IndexFlatIP (inner product) which works well with normalized
+ embeddings like BGE produces.
- The default index type is IndexFlatIP (inner product) which
- works well with normalized embeddings like BGE produces.
+ The index maintains a mapping from FAISS internal positions to chunk IDs,
+ allowing results to be returned with meaningful identifiers rather than
+ raw indices.
Attributes:
----------
- index_path: Path to the saved index file.
- embedding_dim: Dimension of indexed embeddings.
+ embedding_dim : int
+ Dimension of indexed embeddings (read-only).
+
+ num_vectors : int
+ Number of vectors currently in the index (read-only).
+
+ is_trained : bool
+ Whether the index is ready for search (read-only).
+ For IndexFlatIP, this is True after build() or load().
+
+ chunk_ids : list[str]
+ List of chunk IDs in index order (read-only).
Example:
-------
>>> index = FAISSIndex(embedding_dim=384)
+ >>> embeddings = np.random.randn(100, 384).astype(np.float32)
+ >>> chunk_ids = [f"chunk_{i:03d}" for i in range(100)]
>>> index.build(embeddings, chunk_ids)
+ >>> index.num_vectors
+ 100
>>> results = index.search(query_embedding, top_k=5)
+ >>> # results is list of (chunk_id, score) tuples
Note:
----
- This class will be fully implemented in Phase 2 (Step 2.5).
+ FAISS is imported lazily inside methods to avoid loading the
+ heavy library when dense retrieval is not used.
"""
def __init__(self, embedding_dim: int) -> None:
"""Initialize the FAISS index.
+ Creates an empty FAISSIndex configured for the specified embedding
+ dimension. The actual FAISS index is created lazily when build()
+ is called.
+
Args:
----
- embedding_dim: Dimension of embeddings to index.
+ embedding_dim: Dimension of embeddings to index. Must be positive.
+ Common values are 384 (small models), 768 (base models),
+ 1024, or 1536 (large models).
Raises:
------
- NotImplementedError: FAISSIndex will be implemented in Step 2.5.
+ ValueError: If embedding_dim is not a positive integer.
+
+ Example:
+ -------
+ >>> index = FAISSIndex(embedding_dim=384)
+ >>> index.embedding_dim
+ 384
+ >>> index.is_trained
+ False
"""
+ if not isinstance(embedding_dim, int) or embedding_dim <= 0:
+ msg = f"embedding_dim must be a positive integer, got {embedding_dim}"
+ raise ValueError(msg)
+
self._embedding_dim = embedding_dim
- raise NotImplementedError("FAISSIndex will be implemented in Step 2.5")
+ self._index: Any = None # FAISS index, created lazily
+ self._chunk_ids: list[str] = []
+
+ # -------------------------------------------------------------------------
+ # Properties
+ # -------------------------------------------------------------------------
+
+ @property
+ def embedding_dim(self) -> int:
+ """Get the embedding dimension.
+
+ Returns
+ -------
+ Dimension of embeddings in this index.
+
+ """
+ return self._embedding_dim
+
+ @property
+ def num_vectors(self) -> int:
+ """Get the number of vectors in the index.
+
+ Returns
+ -------
+ Number of vectors currently indexed. Returns 0 if
+ the index has not been built or loaded yet.
+
+ """
+ if self._index is None:
+ return 0
+ return int(self._index.ntotal)
+
+ @property
+ def is_trained(self) -> bool:
+ """Check if the index is ready for search.
+
+ For IndexFlatIP, this returns True after build() or load()
+ has been called successfully. IndexFlatIP does not require
+ explicit training, so this effectively checks if the index
+ has been initialized.
+
+ Returns
+ -------
+ True if the index is ready for search, False otherwise.
+
+ """
+ if self._index is None:
+ return False
+ return bool(self._index.is_trained)
+
+ @property
+ def chunk_ids(self) -> list[str]:
+ """Get the list of chunk IDs in index order.
+
+ Returns
+ -------
+ List of chunk IDs corresponding to each vector in the index.
+ The list index matches the FAISS internal index position.
+
+ """
+ return self._chunk_ids.copy()
+
+ # -------------------------------------------------------------------------
+ # Build Methods
+ # -------------------------------------------------------------------------
def build(
self,
- embeddings: Any, # noqa: ANN401 - NDArray[np.float32] at runtime
+ embeddings: Any, # noqa: ANN401 - NDArray[np.float32 | np.float16] at runtime
chunk_ids: list[str],
) -> None:
"""Build the index from embeddings.
+ Creates a FAISS IndexFlatIP index and adds all provided embeddings.
+ The chunk_ids list must have the same length as the number of
+ embedding rows, as it provides the mapping from FAISS positions
+ to chunk identifiers.
+
+ Embeddings may be provided as float16 (from EmbeddingStorage) and
+ will be automatically converted to float32 for FAISS compatibility.
+
Args:
----
embeddings: NumPy array of embeddings with shape
- (num_chunks, embedding_dim).
- chunk_ids: List of chunk identifiers corresponding to
- each embedding row.
+ (num_chunks, embedding_dim). Can be float16 or float32.
+ Each row should be a normalized embedding vector.
+ chunk_ids: List of chunk identifiers corresponding to each
+ embedding row. Length must match embeddings.shape[0].
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ ValueError: If embeddings shape doesn't match expected dimensions.
+ ValueError: If chunk_ids length doesn't match number of embeddings.
+ ValueError: If embeddings array is empty.
+
+ Example:
+ -------
+ >>> index = FAISSIndex(embedding_dim=384)
+ >>> embeddings = np.random.randn(100, 384).astype(np.float32)
+ >>> chunk_ids = [f"chunk_{i:03d}" for i in range(100)]
+ >>> index.build(embeddings, chunk_ids)
+ >>> index.num_vectors
+ 100
+
+ Note:
+ ----
+ Build time is logged at INFO level for performance monitoring.
"""
- raise NotImplementedError("build() will be implemented in Step 2.5")
+ # Lazy import FAISS and numpy
+ import faiss
+ import numpy as np
+
+ start_time = time.perf_counter()
+ logger.info("Building FAISS index with %d vectors...", len(chunk_ids))
+
+ # Validate inputs
+ if embeddings.size == 0:
+ msg = "Cannot build index from empty embeddings array"
+ raise ValueError(msg)
+
+ expected_ndim = 2
+ if len(embeddings.shape) != expected_ndim:
+ msg = f"embeddings must be 2D array, got shape {embeddings.shape}"
+ raise ValueError(msg)
+
+ num_vectors, embed_dim = embeddings.shape
+
+ if embed_dim != self._embedding_dim:
+ msg = (
+ f"Embedding dimension mismatch: expected {self._embedding_dim}, "
+ f"got {embed_dim}"
+ )
+ raise ValueError(msg)
+
+ if len(chunk_ids) != num_vectors:
+ msg = (
+ f"chunk_ids length ({len(chunk_ids)}) must match "
+ f"number of embeddings ({num_vectors})"
+ )
+ raise ValueError(msg)
+
+ # Convert to float32 if needed (FAISS requires float32)
+ if embeddings.dtype != np.float32:
+ logger.debug("Converting embeddings from %s to float32", embeddings.dtype)
+ embeddings = embeddings.astype(np.float32)
+
+ # Ensure C-contiguous array for FAISS
+ if not embeddings.flags["C_CONTIGUOUS"]:
+ embeddings = np.ascontiguousarray(embeddings)
+
+ # Create IndexFlatIP (inner product similarity)
+ # Works well with normalized embeddings like BGE produces
+ self._index = faiss.IndexFlatIP(self._embedding_dim)
+
+ # Add embeddings to the index
+ self._index.add(embeddings)
+
+ # Store chunk ID mapping (position in list = FAISS index position)
+ self._chunk_ids = list(chunk_ids)
+
+ elapsed = time.perf_counter() - start_time
+ logger.info(
+ "FAISS index built: %d vectors, %d dimensions, %.3f seconds",
+ self.num_vectors,
+ self._embedding_dim,
+ elapsed,
+ )
+
+ # -------------------------------------------------------------------------
+ # Search Methods
+ # -------------------------------------------------------------------------
def search(
self,
@@ -103,51 +313,232 @@ class FAISSIndex: # pragma: no cover
) -> list[tuple[str, float]]:
"""Search for nearest neighbors.
+ Finds the top_k most similar vectors to the query embedding using
+ inner product similarity. Results are returned as (chunk_id, score)
+ tuples sorted by score in descending order.
+
Args:
----
- query_embedding: Query vector with shape (embedding_dim,).
- top_k: Number of results to return.
+ query_embedding: Query vector with shape (embedding_dim,) or
+ (1, embedding_dim). Will be converted to float32 if needed.
+ top_k: Number of results to return. Defaults to 10.
+ If top_k exceeds the number of indexed vectors, all
+ vectors are returned.
Returns:
-------
- List of (chunk_id, score) tuples sorted by relevance.
+ List of (chunk_id, score) tuples sorted by score in descending
+ order. The score is the inner product similarity (higher is
+ more similar). May return fewer than top_k results if the
+ index contains fewer vectors.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ RuntimeError: If the index has not been built or loaded yet.
+ ValueError: If query_embedding has wrong dimensions.
+ ValueError: If top_k is not a positive integer.
+
+ Example:
+ -------
+ >>> results = index.search(query_embedding, top_k=5)
+ >>> for chunk_id, score in results:
+ ... print(f"{chunk_id}: {score:.4f}")
+ chunk_042: 0.9234
+ chunk_017: 0.8756
+ chunk_089: 0.8421
"""
- raise NotImplementedError("search() will be implemented in Step 2.5")
+ # Lazy import numpy
+ import numpy as np
+
+ # Validate index state
+ if self._index is None or not self.is_trained:
+ msg = "Index has not been built or loaded yet"
+ raise RuntimeError(msg)
+
+ # Validate top_k
+ if not isinstance(top_k, int) or top_k <= 0:
+ msg = f"top_k must be a positive integer, got {top_k}"
+ raise ValueError(msg)
+
+ # Validate query embedding shape
+ query = np.asarray(query_embedding)
+
+ # Handle 1D array (embedding_dim,) by adding batch dimension
+ if query.ndim == 1:
+ query = query.reshape(1, -1)
+
+ expected_ndim = 2
+ expected_batch_size = 1
+ if query.ndim != expected_ndim or query.shape[0] != expected_batch_size:
+ msg = (
+ f"query_embedding must have shape ({self._embedding_dim},) or "
+ f"(1, {self._embedding_dim}), got shape {query_embedding.shape}"
+ )
+ raise ValueError(msg)
+
+ if query.shape[1] != self._embedding_dim:
+ msg = (
+ f"Query dimension mismatch: expected {self._embedding_dim}, "
+ f"got {query.shape[1]}"
+ )
+ raise ValueError(msg)
+
+ # Convert to float32 if needed
+ if query.dtype != np.float32:
+ query = query.astype(np.float32)
+
+ # Ensure C-contiguous
+ if not query.flags["C_CONTIGUOUS"]:
+ query = np.ascontiguousarray(query)
+
+ # Clamp top_k to index size
+ effective_k = min(top_k, self.num_vectors)
+
+ logger.debug(
+ "Searching FAISS index: top_k=%d, effective_k=%d",
+ top_k,
+ effective_k,
+ )
+
+ # Perform search (returns distances and indices as 2D arrays with shape (1, k))
+ distances, indices = self._index.search(query, effective_k)
+
+ # Build results list
+ results: list[tuple[str, float]] = []
+ for i in range(effective_k):
+ idx = int(indices[0, i])
+ score = float(distances[0, i])
+
+ # FAISS may return -1 for invalid indices (shouldn't happen with
+ # IndexFlatIP, but check anyway)
+ if idx >= 0 and idx < len(self._chunk_ids):
+ chunk_id = self._chunk_ids[idx]
+ results.append((chunk_id, score))
+
+ logger.debug("FAISS search returned %d results", len(results))
+
+ return results
+
+ # -------------------------------------------------------------------------
+ # Persistence Methods
+ # -------------------------------------------------------------------------
def save(self, path: Path) -> None:
"""Save the index to disk.
+ Saves the FAISS index to the specified path and writes the chunk ID
+ mapping to a companion JSON file ({path}.json).
+
Args:
----
- path: File path to save the index.
+ path: File path to save the FAISS index. The JSON mapping file
+ will be created at {path}.json.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ RuntimeError: If the index has not been built yet.
+
+ Example:
+ -------
+ >>> index.save(Path("data/index.faiss"))
+ # Creates:
+ # - data/index.faiss (FAISS binary)
+ # - data/index.faiss.json (chunk ID mapping)
"""
- raise NotImplementedError("save() will be implemented in Step 2.5")
+ # Lazy import FAISS
+ import faiss
+
+ if self._index is None:
+ msg = "Cannot save: index has not been built yet"
+ raise RuntimeError(msg)
+
+ # Ensure parent directory exists
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Save FAISS index
+ faiss.write_index(self._index, str(path))
+
+ # Save chunk ID mapping as JSON
+ mapping_path = path.with_suffix(path.suffix + ".json")
+ mapping_data = {
+ "chunk_ids": self._chunk_ids,
+ "embedding_dim": self._embedding_dim,
+ }
+ with open(mapping_path, "w", encoding="utf-8") as f:
+ json.dump(mapping_data, f, indent=2)
+
+ logger.info(
+ "Saved FAISS index to %s (%d vectors, %d dims)",
+ path,
+ self.num_vectors,
+ self._embedding_dim,
+ )
@classmethod
def load(cls, path: Path) -> FAISSIndex:
"""Load an index from disk.
+ Loads a FAISS index and its chunk ID mapping from disk. The mapping
+ file is expected at {path}.json.
+
Args:
----
- path: File path to load the index from.
+ path: File path to the FAISS index file.
Returns:
-------
- Loaded FAISSIndex instance.
+ New FAISSIndex instance with the loaded index and mapping.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ FileNotFoundError: If the index file or mapping file doesn't exist.
+
+ Example:
+ -------
+ >>> index = FAISSIndex.load(Path("data/index.faiss"))
+ >>> index.num_vectors
+ 100
+ >>> results = index.search(query, top_k=5)
"""
- raise NotImplementedError("load() will be implemented in Step 2.5")
+ # Lazy import FAISS
+ import faiss
+
+ path = Path(path)
+
+ # Check index file exists
+ if not path.exists():
+ msg = f"FAISS index file not found: {path}"
+ raise FileNotFoundError(msg)
+
+ # Check mapping file exists
+ mapping_path = path.with_suffix(path.suffix + ".json")
+ if not mapping_path.exists():
+ msg = f"FAISS mapping file not found: {mapping_path}"
+ raise FileNotFoundError(msg)
+
+ # Load mapping first to get embedding_dim
+ with open(mapping_path, encoding="utf-8") as f:
+ mapping_data = json.load(f)
+
+ embedding_dim = mapping_data["embedding_dim"]
+ chunk_ids = mapping_data["chunk_ids"]
+
+ # Create instance with correct dimension
+ instance = cls(embedding_dim=embedding_dim)
+
+ # Load FAISS index
+ instance._index = faiss.read_index(str(path))
+ instance._chunk_ids = chunk_ids
+
+ logger.info(
+ "Loaded FAISS index from %s (%d vectors, %d dims)",
+ path,
+ instance.num_vectors,
+ instance._embedding_dim,
+ )
+
+ return instance
diff --git a/src/rag_chatbot/retrieval/hybrid.py b/src/rag_chatbot/retrieval/hybrid.py
index 9fc547a76a080d5a4a84d05cce80863a9bf4b577..940d5cdcca8a7f197f0088479185375412c4afec 100644
--- a/src/rag_chatbot/retrieval/hybrid.py
+++ b/src/rag_chatbot/retrieval/hybrid.py
@@ -7,177 +7,898 @@ Fusion (RRF) for result merging. This hybrid approach provides:
- Keyword precision from BM25
- Robust handling of diverse queries
-Lazy Loading:
- Retriever components are loaded on first use.
+The hybrid approach is particularly effective for:
+ - Technical documentation: Captures both concepts and exact terms
+ - Multi-faceted queries: Balances semantic and lexical matching
+ - Out-of-vocabulary terms: BM25 catches terms not in embedding vocab
+
+RRF Algorithm:
+ Reciprocal Rank Fusion (RRF) combines ranked lists from multiple
+ retrievers by assigning scores based on rank position. The formula:
+ score(d) = sum_{i} weight_i * (1 / (k + rank_i(d)))
+
+ Where:
+ - k is a constant (default 60) that prevents top results from
+ dominating too heavily
+ - weight_i is the weight for retriever i (e.g., 0.7 for dense)
+ - rank_i(d) is the 1-based rank of document d in retriever i's list
+
+ RRF advantages:
+ - Robust to score scale differences between retrievers
+ - Simple and parameter-light
+ - Handles partial overlap gracefully
-Note:
-----
- This is a placeholder that will be fully implemented in Step 2.5.
+Lazy Loading:
+ Retriever components are loaded on first use, following the project
+ convention for heavy dependencies (torch, faiss, sentence-transformers).
+
+Example:
+-------
+ >>> from pathlib import Path
+ >>> from rag_chatbot.retrieval import HybridRetriever
+ >>> # Load from saved indexes
+ >>> retriever = HybridRetriever.from_path(Path("data/index"))
+ >>> # Retrieve with hybrid (dense + BM25)
+ >>> results = retriever.retrieve("thermal comfort PMV model", top_k=5)
+ >>> for result in results:
+ ... print(f"[{result.score:.3f}] {result.chunk_id}")
+ ... print(f" {result.text[:80]}...")
+
+See Also:
+--------
+ - DenseRetriever: Dense retrieval using FAISS and BGE embeddings
+ - BM25Retriever: Sparse keyword-based retrieval
+ - https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf (RRF paper)
"""
from __future__ import annotations
+import logging
+from pathlib import Path
from typing import TYPE_CHECKING
-if TYPE_CHECKING:
- from pathlib import Path
+# Import the models directly (lightweight Pydantic models)
+from .models import RetrievalResult
+if TYPE_CHECKING:
from .bm25 import BM25Retriever
- from .faiss_index import FAISSIndex
+ from .chunk_store import ChunkStore
+ from .dense import DenseRetriever
# =============================================================================
# Module Exports
# =============================================================================
-__all__: list[str] = ["HybridRetriever", "RetrievalResult"]
+__all__: list[str] = ["HybridRetriever"]
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
-class RetrievalResult: # pragma: no cover
- """Represents a single retrieval result.
+# =============================================================================
+# Constants
+# =============================================================================
- Attributes:
- ----------
- chunk_id: Identifier of the retrieved chunk.
- content: Text content of the chunk.
- score: Relevance score from retrieval.
- source: Source of the score ('dense', 'sparse', or 'hybrid').
+# Maximum length of query to show in log messages before truncation
+_MAX_QUERY_LOG_LENGTH: int = 50
- Note:
- ----
- This class will be converted to a Pydantic model in Step 2.5.
- """
+class HybridRetriever:
+ """Hybrid retriever combining dense and sparse methods.
- def __init__(
- self,
- chunk_id: str,
- content: str,
- score: float,
- source: str,
- ) -> None:
- """Initialize a retrieval result.
+ This class combines FAISS dense retrieval with BM25 sparse retrieval
+ using Reciprocal Rank Fusion (RRF) to merge results. The hybrid
+ approach provides robust retrieval for diverse query types.
- Args:
- ----
- chunk_id: Identifier of the retrieved chunk.
- content: Text content of the chunk.
- score: Relevance score from retrieval.
- source: Source of the score ('dense', 'sparse', or 'hybrid').
+ RRF Formula:
+ score(d) = sum(weight_i * (1 / (k + rank_i(d)))) for each retriever i
- Raises:
- ------
- NotImplementedError: RetrievalResult will be implemented in Step 2.5.
+ Where k is a constant (typically 60) that prevents high-ranked
+ documents from dominating the final score too heavily.
- """
- raise NotImplementedError("RetrievalResult will be implemented in Step 2.5")
+ Score Normalization:
+ Final RRF scores are normalized to [0, 1] using min-max normalization
+ to ensure compatibility with the RetrievalResult model and provide
+ consistent score interpretation.
+ Operating Modes:
+ - use_hybrid=True (default): Combines dense and BM25 using RRF
+ - use_hybrid=False: Uses only dense retrieval (faster, semantic-only)
-class HybridRetriever: # pragma: no cover
- """Hybrid retriever combining dense and sparse methods.
+ Attributes:
+ ----------
+ dense_retriever : DenseRetriever
+ Dense retriever using FAISS for semantic similarity search.
- This class combines FAISS dense retrieval with BM25 sparse
- retrieval using Reciprocal Rank Fusion (RRF) to merge results.
- The hybrid approach provides robust retrieval for diverse
- query types.
+ bm25_retriever : BM25Retriever
+ BM25 retriever for keyword-based search.
- RRF Formula:
- score(d) = sum(1 / (k + rank_i(d))) for each retriever i
+ chunk_store : ChunkStore
+ Store for chunk metadata lookup.
- Where k is a constant (typically 60) that prevents high-ranked
- documents from dominating the final score.
+ use_hybrid : bool
+ Whether to use hybrid retrieval or dense only.
- Attributes:
- ----------
- dense_index: FAISS index for dense retrieval.
- sparse_retriever: BM25 retriever for sparse retrieval.
- use_hybrid: Whether to use hybrid retrieval or dense only.
- rrf_k: RRF constant for score calculation.
+ dense_weight : float
+ Weight for dense retrieval scores in RRF.
+
+ sparse_weight : float
+ Weight for BM25 retrieval scores in RRF.
+
+ rrf_k : int
+ RRF constant for score calculation.
Example:
-------
- >>> retriever = HybridRetriever.from_path(Path("data/index"))
+ >>> # Create from individual components
+ >>> retriever = HybridRetriever(
+ ... dense_retriever=dense,
+ ... bm25_retriever=bm25,
+ ... chunk_store=chunks,
+ ... dense_weight=0.7,
+ ... sparse_weight=0.3,
+ ... )
>>> results = retriever.retrieve("thermal comfort PMV", top_k=5)
>>> for result in results:
- ... print(f"{result.chunk_id}: {result.score}")
+ ... print(f"{result.chunk_id}: {result.score:.3f}")
+
+ >>> # Create from saved indexes using from_path
+ >>> retriever = HybridRetriever.from_path(Path("data/index"))
Note:
----
- This class will be fully implemented in Phase 2 (Step 2.5).
+ This class implements the Retriever protocol defined in
+ rag_chatbot.retrieval.models, enabling use with dependency
+ injection and testing frameworks.
"""
- def __init__(
+ def __init__( # noqa: PLR0913
self,
- dense_index: FAISSIndex,
- sparse_retriever: BM25Retriever,
+ dense_retriever: DenseRetriever,
+ bm25_retriever: BM25Retriever,
+ chunk_store: ChunkStore,
use_hybrid: bool = True,
+ dense_weight: float = 0.7,
+ sparse_weight: float = 0.3,
rrf_k: int = 60,
) -> None:
"""Initialize the hybrid retriever.
+ Creates a HybridRetriever with the specified dense and sparse
+ retrievers, chunk store, and RRF configuration parameters.
+
Args:
----
- dense_index: FAISS index for dense retrieval.
- sparse_retriever: BM25 retriever for sparse retrieval.
- use_hybrid: Whether to use both retrievers (True) or
- dense only (False). Defaults to True.
- rrf_k: RRF constant for score calculation. Higher values
- reduce the influence of rank. Defaults to 60.
+ dense_retriever: Dense retriever using FAISS for semantic search.
+ Must be a fully initialized DenseRetriever instance.
+ bm25_retriever: BM25 retriever for keyword-based search.
+ Must be a built BM25Retriever instance (build() called).
+ chunk_store: Store for chunk metadata lookup.
+ Must be initialized with the chunks JSONL file.
+ use_hybrid: Whether to use both retrievers (True) or dense
+ only (False). Defaults to True. Set to False for faster
+ retrieval when keyword matching is not needed.
+ dense_weight: Weight for dense retrieval scores in RRF fusion.
+ Defaults to 0.7. Higher values favor semantic matches.
+ Must be non-negative.
+ sparse_weight: Weight for BM25 retrieval scores in RRF fusion.
+ Defaults to 0.3. Higher values favor keyword matches.
+ Must be non-negative.
+ rrf_k: RRF constant for score calculation. Higher values reduce
+ the influence of top ranks. Typical values are 30-100.
+ Defaults to 60 (standard RRF setting).
Raises:
------
- NotImplementedError: HybridRetriever will be implemented in Step 2.5.
+ ValueError: If dense_retriever, bm25_retriever, or chunk_store is None.
+ ValueError: If dense_weight or sparse_weight is negative.
+ ValueError: If rrf_k is not a positive integer.
+
+ Example:
+ -------
+ >>> retriever = HybridRetriever(
+ ... dense_retriever=dense,
+ ... bm25_retriever=bm25,
+ ... chunk_store=chunks,
+ ... use_hybrid=True,
+ ... dense_weight=0.7,
+ ... sparse_weight=0.3,
+ ... rrf_k=60,
+ ... )
+
+ Note:
+ ----
+ The dense_weight and sparse_weight do not need to sum to 1.0.
+ They are multiplied with the RRF scores and the final scores
+ are normalized to [0, 1] after fusion.
+
+ """
+ # =================================================================
+ # Validate required parameters
+ # =================================================================
+ if dense_retriever is None:
+ msg = "dense_retriever cannot be None"
+ raise ValueError(msg)
+
+ if bm25_retriever is None:
+ msg = "bm25_retriever cannot be None"
+ raise ValueError(msg)
+
+ if chunk_store is None:
+ msg = "chunk_store cannot be None"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Validate weight parameters
+ # =================================================================
+ if dense_weight < 0:
+ msg = f"dense_weight must be non-negative, got {dense_weight}"
+ raise ValueError(msg)
+
+ if sparse_weight < 0:
+ msg = f"sparse_weight must be non-negative, got {sparse_weight}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Validate RRF constant
+ # =================================================================
+ if not isinstance(rrf_k, int) or rrf_k <= 0:
+ msg = f"rrf_k must be a positive integer, got {rrf_k}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Store components
+ # =================================================================
+ self._dense_retriever: DenseRetriever = dense_retriever
+ self._bm25_retriever: BM25Retriever = bm25_retriever
+ self._chunk_store: ChunkStore = chunk_store
+
+ # =================================================================
+ # Store configuration
+ # =================================================================
+ self._use_hybrid: bool = use_hybrid
+ self._dense_weight: float = dense_weight
+ self._sparse_weight: float = sparse_weight
+ self._rrf_k: int = rrf_k
+
+ logger.debug(
+ "Initialized HybridRetriever: use_hybrid=%s, dense_weight=%.2f, "
+ "sparse_weight=%.2f, rrf_k=%d",
+ self._use_hybrid,
+ self._dense_weight,
+ self._sparse_weight,
+ self._rrf_k,
+ )
+
+ # -------------------------------------------------------------------------
+ # Private Methods
+ # -------------------------------------------------------------------------
+
+ def _compute_rrf_score(self, rank: int, weight: float) -> float:
+ """Compute the weighted RRF score for a document at a given rank.
+
+ RRF Formula:
+ score = weight * (1 / (k + rank))
+
+ Where:
+ - k is the RRF constant (default 60)
+ - rank is the 1-based rank position
+ - weight is the retriever weight (e.g., 0.7 for dense)
+
+ Args:
+ ----
+ rank: The 1-based rank position of the document.
+ Must be >= 1.
+ weight: The weight for this retriever's contribution.
+
+ Returns:
+ -------
+ The weighted RRF score for the document.
+
+ Example:
+ -------
+ >>> retriever._compute_rrf_score(rank=1, weight=0.7)
+ 0.01147540983606557 # 0.7 * 1/(60+1)
+ >>> retriever._compute_rrf_score(rank=5, weight=0.7)
+ 0.010769230769230769 # 0.7 * 1/(60+5)
+
+ Note:
+ ----
+ The RRF constant k=60 prevents the top rank from having
+ disproportionate influence. With k=60:
+ - rank 1: score = weight * 1/61 = 0.0164 * weight
+ - rank 10: score = weight * 1/70 = 0.0143 * weight
+ The difference between ranks is smoothed out.
+
+ """
+ # RRF formula: weight * (1 / (k + rank))
+ return weight * (1.0 / (self._rrf_k + rank))
+
+ def _normalize_scores(self, chunk_scores: dict[str, float]) -> dict[str, float]:
+ """Normalize RRF scores to [0, 1] range using min-max normalization.
+
+ Takes the raw combined RRF scores and normalizes them so that:
+ - The highest score becomes 1.0
+ - The lowest score becomes 0.0
+ - All other scores are proportionally scaled
+
+ This normalization ensures that scores are consistent with the
+ RetrievalResult model's score constraints and provides meaningful
+ relative rankings.
+
+ Args:
+ ----
+ chunk_scores: Dictionary mapping chunk_id to raw RRF score.
+
+ Returns:
+ -------
+ Dictionary mapping chunk_id to normalized score in [0, 1].
+
+ Example:
+ -------
+ >>> scores = {"c1": 0.02, "c2": 0.015, "c3": 0.01}
+ >>> normalized = retriever._normalize_scores(scores)
+ >>> # c1 -> 1.0, c3 -> 0.0, c2 -> 0.5
+
+ Note:
+ ----
+ - If all scores are equal, returns 1.0 for all (equally relevant)
+ - If only one document, returns 1.0 for it
+ - Empty input returns empty output
"""
- self._dense_index = dense_index
- self._sparse_retriever = sparse_retriever
- self._use_hybrid = use_hybrid
- self._rrf_k = rrf_k
- raise NotImplementedError("HybridRetriever will be implemented in Step 2.5")
+ # Handle empty input
+ if not chunk_scores:
+ return {}
+
+ scores = list(chunk_scores.values())
+
+ # Find min and max for normalization
+ min_score = min(scores)
+ max_score = max(scores)
+ score_range = max_score - min_score
+
+ # Handle case where all scores are equal
+ if score_range == 0:
+ # All documents are equally relevant, return 1.0 for all
+ return {chunk_id: 1.0 for chunk_id in chunk_scores}
+
+ # Apply min-max normalization
+ normalized: dict[str, float] = {}
+ for chunk_id, score in chunk_scores.items():
+ normalized[chunk_id] = (score - min_score) / score_range
- def retrieve(
+ return normalized
+
+ def _fuse_results(
self,
- query: str,
- top_k: int = 6,
+ dense_results: list[RetrievalResult],
+ bm25_results: list[tuple[str, float]],
+ top_k: int,
) -> list[RetrievalResult]:
- """Retrieve relevant chunks for a query.
+ """Fuse dense and BM25 results using Reciprocal Rank Fusion.
+
+ This method implements the core RRF algorithm:
+ 1. Compute RRF scores for each document from each retriever
+ 2. Apply retriever weights to the RRF scores
+ 3. Combine scores for documents appearing in multiple lists
+ 4. Normalize final scores to [0, 1]
+ 5. Sort by score and return top_k results
+
+ Args:
+ ----
+ dense_results: Results from dense retriever as RetrievalResult list.
+ Each result has chunk_id and other metadata.
+ bm25_results: Results from BM25 retriever as (chunk_id, score) tuples.
+ The scores from BM25 are not used; only rank matters for RRF.
+ top_k: Maximum number of results to return.
+
+ Returns:
+ -------
+ List of RetrievalResult objects sorted by fused score descending.
+ Contains at most top_k results.
+
+ Note:
+ ----
+ Documents appearing in both retriever results get combined scores.
+ Documents appearing in only one result get score from that retriever
+ only (no penalty for missing from other retriever).
+
+ """
+ # =================================================================
+ # Step 1: Initialize score accumulator
+ # =================================================================
+ # Dictionary to accumulate RRF scores for each chunk_id
+ # Each chunk may appear in one or both retriever results
+ # =================================================================
+ chunk_scores: dict[str, float] = {}
+
+ # =================================================================
+ # Step 2: Compute RRF scores from dense retrieval
+ # =================================================================
+ # Dense results are already RetrievalResult objects
+ # Rank is 1-based (first result has rank 1)
+ # =================================================================
+ for rank, result in enumerate(dense_results, start=1):
+ chunk_id = result.chunk_id
+ rrf_score = self._compute_rrf_score(rank, self._dense_weight)
+
+ # Add to accumulator (may already have score from BM25)
+ if chunk_id in chunk_scores:
+ chunk_scores[chunk_id] += rrf_score
+ else:
+ chunk_scores[chunk_id] = rrf_score
+
+ logger.debug("Dense retrieval contributed %d unique chunks", len(dense_results))
+
+ # =================================================================
+ # Step 3: Compute RRF scores from BM25 retrieval
+ # =================================================================
+ # BM25 results are (chunk_id, score) tuples
+ # Only chunk_id is used; BM25 scores are replaced by RRF scores
+ # =================================================================
+ for rank, (chunk_id, _bm25_score) in enumerate(bm25_results, start=1):
+ rrf_score = self._compute_rrf_score(rank, self._sparse_weight)
+
+ # Add to accumulator (may already have score from dense)
+ if chunk_id in chunk_scores:
+ chunk_scores[chunk_id] += rrf_score
+ else:
+ chunk_scores[chunk_id] = rrf_score
+
+ logger.debug("BM25 retrieval contributed %d unique chunks", len(bm25_results))
+ logger.debug("After fusion: %d unique chunks total", len(chunk_scores))
+
+ # =================================================================
+ # Step 4: Normalize scores to [0, 1] range
+ # =================================================================
+ # Min-max normalization ensures consistent score interpretation
+ # =================================================================
+ normalized_scores = self._normalize_scores(chunk_scores)
+
+ # =================================================================
+ # Step 5: Sort by score and select top_k
+ # =================================================================
+ # Sort chunk_ids by their normalized score in descending order
+ # =================================================================
+ sorted_chunks = sorted(
+ normalized_scores.items(),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+
+ # Select top_k results
+ top_chunks = sorted_chunks[:top_k]
+
+ # =================================================================
+ # Step 6: Build RetrievalResult objects
+ # =================================================================
+ # Look up chunk metadata from chunk_store
+ # Skip any chunks that are missing from the store (shouldn't happen)
+ # =================================================================
+ results: list[RetrievalResult] = []
+
+ for chunk_id, score in top_chunks:
+ # Look up chunk metadata
+ chunk = self._chunk_store.get(chunk_id)
+
+ if chunk is None:
+ # Log warning but continue (index/store mismatch)
+ logger.warning(
+ "Chunk %r in RRF results but not found in chunk store, skipping",
+ chunk_id,
+ )
+ continue
+
+ # Create RetrievalResult with hybrid score
+ try:
+ result = RetrievalResult(
+ chunk_id=chunk_id,
+ text=chunk.text,
+ score=score,
+ heading_path=chunk.heading_path,
+ source=chunk.source,
+ page=chunk.page,
+ )
+ results.append(result)
+ except Exception as e:
+ # Log validation errors but continue with other results
+ logger.warning(
+ "Failed to create RetrievalResult for chunk %r: %s",
+ chunk_id,
+ str(e),
+ )
+ continue
+
+ return results
+
+ # -------------------------------------------------------------------------
+ # Public Methods (Retriever Protocol)
+ # -------------------------------------------------------------------------
+
+ def retrieve(self, query: str, top_k: int = 6) -> list[RetrievalResult]:
+ """Retrieve relevant chunks for a given query.
+
+ Performs hybrid retrieval by combining dense (FAISS) and sparse (BM25)
+ retrieval results using Reciprocal Rank Fusion. When use_hybrid=False,
+ only dense retrieval is used.
+
+ Processing Steps (Hybrid Mode):
+ 1. Validate query and top_k parameters
+ 2. Get top_k*2 results from dense retriever (over-fetch for fusion)
+ 3. Get top_k*2 results from BM25 retriever (over-fetch for fusion)
+ 4. Apply RRF scoring with weights to combine results
+ 5. Deduplicate by chunk_id (scores are summed)
+ 6. Normalize final scores to [0, 1] range
+ 7. Sort by combined score and return top_k
+
+ Processing Steps (Dense-Only Mode):
+ 1. Validate query and top_k parameters
+ 2. Delegate to dense retriever
+ 3. Return results directly
Args:
----
- query: Search query string.
- top_k: Number of results to return.
+ query: The search query string. Should be a natural language
+ question or keyword phrase. Will be normalized internally.
+ top_k: Maximum number of results to return. Defaults to 6.
+ Must be a positive integer. The method over-fetches
+ (top_k * 2) from each retriever to ensure enough candidates
+ for fusion.
Returns:
-------
- List of RetrievalResult objects sorted by relevance.
+ List of RetrievalResult objects sorted by relevance score in
+ descending order (highest relevance first). Each result contains:
+ - chunk_id: Unique identifier of the chunk
+ - text: Full text content of the chunk
+ - score: Normalized relevance score [0, 1]
+ - heading_path: Hierarchical heading context
+ - source: Source document name
+ - page: Page number in source document
+
+ Returns empty list if:
+ - The index is empty (no vectors)
+ - The query is empty after normalization
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ ValueError: If query is empty or top_k is not positive.
+ RuntimeError: If retrieval fails due to index issues.
+
+ Example:
+ -------
+ >>> results = retriever.retrieve("What is PMV?", top_k=5)
+ >>> for result in results:
+ ... print(f"[{result.score:.3f}] {result.chunk_id}")
+ ... print(f" Source: {result.source}, Page: {result.page}")
+ ... print(f" Text: {result.text[:80]}...")
+ [0.945] ashrae55_042
+ Source: ashrae_55.pdf, Page: 15
+ Text: The PMV (Predicted Mean Vote) is an index that predicts the mean...
+ [0.812] iso7730_015
+ Source: iso_7730.pdf, Page: 8
+ Text: PMV is calculated using the following equation...
+
+ Note:
+ ----
+ In hybrid mode, chunks appearing in both dense and BM25 results
+ receive combined scores from both retrievers. This helps promote
+ documents that are both semantically relevant and keyword-matching.
"""
- raise NotImplementedError("retrieve() will be implemented in Step 2.5")
+ # =================================================================
+ # Step 1: Validate input parameters
+ # =================================================================
+ if not query:
+ msg = "query cannot be empty"
+ raise ValueError(msg)
+
+ if not isinstance(top_k, int) or top_k <= 0:
+ msg = f"top_k must be a positive integer, got {top_k}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 2: Check operating mode
+ # =================================================================
+ # If use_hybrid is False, delegate entirely to dense retriever
+ # =================================================================
+ if not self._use_hybrid:
+ query_preview = (
+ query[:_MAX_QUERY_LOG_LENGTH] + "..."
+ if len(query) > _MAX_QUERY_LOG_LENGTH
+ else query
+ )
+ logger.debug("Using dense-only mode for query: %r", query_preview)
+ return self._dense_retriever.retrieve(query, top_k)
+
+ # =================================================================
+ # Step 3: Over-fetch from both retrievers for fusion
+ # =================================================================
+ # We fetch top_k * 2 from each retriever to ensure we have enough
+ # candidates after fusion and deduplication. This is a common
+ # practice in hybrid retrieval to avoid missing relevant documents
+ # that might rank differently across retrievers.
+ # =================================================================
+ fetch_k = top_k * 2
+
+ logger.debug(
+ "Hybrid retrieval: fetching %d candidates from each retriever",
+ fetch_k,
+ )
+
+ # Get dense results (already RetrievalResult objects)
+ dense_results = self._dense_retriever.retrieve(query, fetch_k)
+
+ # Get BM25 results (list of (chunk_id, score) tuples)
+ bm25_results = self._bm25_retriever.retrieve(query, fetch_k)
+
+ logger.debug(
+ "Retrieved %d dense, %d BM25 candidates",
+ len(dense_results),
+ len(bm25_results),
+ )
+
+ # =================================================================
+ # Step 4: Fuse results using RRF
+ # =================================================================
+ # The _fuse_results method handles:
+ # - Computing RRF scores for each retriever
+ # - Applying weights
+ # - Combining scores for overlapping documents
+ # - Normalizing final scores
+ # - Sorting and selecting top_k
+ # =================================================================
+ fused_results = self._fuse_results(dense_results, bm25_results, top_k)
+
+ # Log summary for debugging
+ query_preview = (
+ query[:_MAX_QUERY_LOG_LENGTH] + "..."
+ if len(query) > _MAX_QUERY_LOG_LENGTH
+ else query
+ )
+ logger.info(
+ "Hybrid retrieval returned %d results for query: %r",
+ len(fused_results),
+ query_preview,
+ )
+
+ return fused_results
+
+ # -------------------------------------------------------------------------
+ # Class Methods
+ # -------------------------------------------------------------------------
@classmethod
- def from_path(
+ def from_path( # noqa: PLR0913
cls,
index_path: Path,
use_hybrid: bool = True,
+ dense_weight: float = 0.7,
+ sparse_weight: float = 0.3,
+ rrf_k: int = 60,
) -> HybridRetriever:
"""Load a hybrid retriever from saved indexes.
+ Creates a HybridRetriever by loading pre-built FAISS index, BM25 index,
+ and chunks from the specified directory. This is the recommended way
+ to create a HybridRetriever for production use.
+
+ Expected Directory Structure:
+ index_path/
+ faiss.index - FAISS index binary file
+ faiss.index.json - FAISS chunk ID mapping
+ bm25.pkl - BM25 pickled index
+ chunks.jsonl - Chunk metadata
+
Args:
----
index_path: Directory containing saved index files.
- use_hybrid: Whether to use hybrid retrieval.
+ Must contain the files listed above.
+ use_hybrid: Whether to use hybrid retrieval (True) or
+ dense only (False). Defaults to True.
+ dense_weight: Weight for dense retrieval in RRF.
+ Defaults to 0.7.
+ sparse_weight: Weight for BM25 retrieval in RRF.
+ Defaults to 0.3.
+ rrf_k: RRF constant for score calculation.
+ Defaults to 60.
Returns:
-------
- Loaded HybridRetriever instance.
+ Loaded HybridRetriever instance ready for retrieval.
Raises:
------
- NotImplementedError: Method will be implemented in Step 2.5.
+ FileNotFoundError: If any required index files are missing.
+ ValueError: If index_path is not a directory.
+
+ Example:
+ -------
+ >>> retriever = HybridRetriever.from_path(
+ ... Path("data/index"),
+ ... use_hybrid=True,
+ ... dense_weight=0.7,
+ ... sparse_weight=0.3,
+ ... )
+ >>> results = retriever.retrieve("thermal comfort", top_k=5)
+
+ Note:
+ ----
+ This method loads the FAISS index, BM25 index, and chunk store
+ lazily (files are read but heavy processing is deferred).
+ The BGE encoder for dense retrieval is loaded on first query.
+
+ """
+ # =================================================================
+ # Step 1: Validate index_path is a directory
+ # =================================================================
+ index_path = Path(index_path)
+
+ if not index_path.is_dir():
+ msg = f"index_path must be a directory, got: {index_path}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 2: Define expected file paths
+ # =================================================================
+ faiss_path = index_path / "faiss.index"
+ bm25_path = index_path / "bm25.pkl"
+ chunks_path = index_path / "chunks.jsonl"
+
+ # =================================================================
+ # Step 3: Check all required files exist
+ # =================================================================
+ missing_files: list[str] = []
+
+ if not faiss_path.exists():
+ missing_files.append(str(faiss_path))
+
+ if not bm25_path.exists():
+ missing_files.append(str(bm25_path))
+
+ if not chunks_path.exists():
+ missing_files.append(str(chunks_path))
+
+ if missing_files:
+ msg = f"Missing required index files: {', '.join(missing_files)}"
+ raise FileNotFoundError(msg)
+
+ logger.info("Loading hybrid retriever from %s...", index_path)
+
+ # =================================================================
+ # Step 4: Load components (lazy imports to avoid heavy deps)
+ # =================================================================
+ # Import here to follow lazy loading pattern
+ # =================================================================
+ from .bm25 import BM25Retriever
+ from .chunk_store import ChunkStore
+ from .dense import DenseRetriever
+ from .faiss_index import FAISSIndex
+
+ # Load FAISS index
+ faiss_index = FAISSIndex.load(faiss_path)
+ logger.debug("Loaded FAISS index with %d vectors", faiss_index.num_vectors)
+
+ # Load BM25 index
+ bm25_retriever = BM25Retriever.load(bm25_path)
+ logger.debug("Loaded BM25 index")
+
+ # Load chunk store
+ chunk_store = ChunkStore(chunks_path)
+ logger.debug("Initialized chunk store from %s", chunks_path)
+
+ # =================================================================
+ # Step 5: Create DenseRetriever from FAISS index
+ # =================================================================
+ # The DenseRetriever wraps the FAISS index and chunk store
+ # The encoder is lazy loaded on first retrieve() call
+ # =================================================================
+ dense_retriever = DenseRetriever(
+ faiss_index=faiss_index,
+ chunk_store=chunk_store,
+ )
+
+ # =================================================================
+ # Step 6: Create and return HybridRetriever
+ # =================================================================
+ logger.info(
+ "Loaded HybridRetriever: use_hybrid=%s, dense_weight=%.2f, "
+ "sparse_weight=%.2f",
+ use_hybrid,
+ dense_weight,
+ sparse_weight,
+ )
+
+ return cls(
+ dense_retriever=dense_retriever,
+ bm25_retriever=bm25_retriever,
+ chunk_store=chunk_store,
+ use_hybrid=use_hybrid,
+ dense_weight=dense_weight,
+ sparse_weight=sparse_weight,
+ rrf_k=rrf_k,
+ )
+
+ # -------------------------------------------------------------------------
+ # Properties
+ # -------------------------------------------------------------------------
+
+ @property
+ def dense_retriever(self) -> DenseRetriever:
+ """Get the dense retriever.
+
+ Returns
+ -------
+ The DenseRetriever instance used for semantic search.
+
+ """
+ return self._dense_retriever
+
+ @property
+ def bm25_retriever(self) -> BM25Retriever:
+ """Get the BM25 retriever.
+
+ Returns
+ -------
+ The BM25Retriever instance used for keyword search.
+
+ """
+ return self._bm25_retriever
+
+ @property
+ def chunk_store(self) -> ChunkStore:
+ """Get the chunk store.
+
+ Returns
+ -------
+ The ChunkStore instance used for metadata lookup.
+
+ """
+ return self._chunk_store
+
+ @property
+ def use_hybrid(self) -> bool:
+ """Check if hybrid mode is enabled.
+
+ Returns
+ -------
+ True if hybrid retrieval is enabled, False for dense-only.
+
+ """
+ return self._use_hybrid
+
+ @property
+ def dense_weight(self) -> float:
+ """Get the dense retrieval weight.
+
+ Returns
+ -------
+ Weight for dense retrieval in RRF fusion.
+
+ """
+ return self._dense_weight
+
+ @property
+ def sparse_weight(self) -> float:
+ """Get the BM25 retrieval weight.
+
+ Returns
+ -------
+ Weight for BM25 retrieval in RRF fusion.
+
+ """
+ return self._sparse_weight
+
+ @property
+ def rrf_k(self) -> int:
+ """Get the RRF constant.
+
+ Returns
+ -------
+ The RRF k constant used in score calculation.
"""
- raise NotImplementedError("from_path() will be implemented in Step 2.5")
+ return self._rrf_k
diff --git a/src/rag_chatbot/retrieval/reranker.py b/src/rag_chatbot/retrieval/reranker.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff0d75db3f8da399e040d0397ac2fa1f1e96cf14
--- /dev/null
+++ b/src/rag_chatbot/retrieval/reranker.py
@@ -0,0 +1,573 @@
+"""Optional reranker for improved retrieval result ranking.
+
+This module provides the Reranker class which uses a cross-encoder model
+to rerank retrieval results for improved relevance. Cross-encoders score
+query-document pairs jointly, providing more accurate relevance judgments
+than bi-encoder similarity scores.
+
+The reranker is designed as an optional post-processing step in the
+retrieval pipeline. It can significantly improve result quality at the
+cost of additional latency, making it suitable for:
+ - High-precision use cases where accuracy is critical
+ - Smaller result sets where latency is acceptable
+ - Scenarios where initial retrieval may return noisy results
+
+Design Decisions:
+ - Disabled by default to maintain low latency for most queries
+ - Uses CPU execution by default (no GPU required)
+ - Scores normalized to [0, 1] using sigmoid for consistency
+ - Latency is logged for monitoring and optimization
+
+Lazy Loading:
+ The CrossEncoder model is loaded on first rerank() call, not at module
+ import time. This follows the project convention of lazy-loading heavy
+ dependencies (torch, sentence-transformers) to ensure:
+ - Fast import times
+ - Minimal memory usage until reranking is needed
+ - Compatibility with environments without optional dependencies
+
+Cross-Encoder vs Bi-Encoder:
+ - Bi-encoders (like BGE) encode query and document independently, then
+ compute similarity. Fast but less accurate.
+ - Cross-encoders process query and document together, capturing their
+ interaction. Slower but more accurate.
+ - We use cross-encoder as a second-stage ranker after bi-encoder retrieval.
+
+Score Normalization:
+ Cross-encoder models output raw logits (any real number). We normalize
+ these to [0, 1] using the sigmoid function:
+ normalized = 1 / (1 + exp(-score))
+ This maps:
+ - Large positive scores -> ~1.0 (high relevance)
+ - Zero -> 0.5 (neutral)
+ - Large negative scores -> ~0.0 (low relevance)
+
+Example:
+-------
+ >>> from rag_chatbot.retrieval import Reranker, RetrievalResult
+ >>> # Create reranker (model loaded lazily)
+ >>> reranker = Reranker()
+ >>> # Rerank results
+ >>> results = [...] # List of RetrievalResult from initial retrieval
+ >>> reranked = reranker.rerank("What is PMV?", results, top_k=5)
+ >>> # Results are now sorted by cross-encoder relevance
+
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import time
+from typing import TYPE_CHECKING
+
+# =============================================================================
+# Type Checking Imports
+# =============================================================================
+# These imports are only processed by type checkers (mypy, pyright) and IDEs.
+# They enable proper type hints without runtime overhead.
+# The CrossEncoder import is heavy (loads torch, sentence-transformers) so we
+# only import it for type checking, not at runtime.
+# =============================================================================
+
+if TYPE_CHECKING:
+ from sentence_transformers import CrossEncoder
+
+# Import the models directly (lightweight Pydantic models)
+from .models import RetrievalResult
+
+# =============================================================================
+# Module Exports
+# =============================================================================
+__all__: list[str] = ["Reranker"]
+
+# =============================================================================
+# Logger
+# =============================================================================
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Default model for cross-encoder reranking
+# bge-reranker-base is a strong general-purpose reranker with good
+# accuracy/latency tradeoff
+DEFAULT_RERANKER_MODEL: str = "BAAI/bge-reranker-base"
+
+# Default device for inference (CPU for compatibility)
+DEFAULT_DEVICE: str = "cpu"
+
+# Maximum length of query to show in log messages (truncate longer queries)
+_MAX_QUERY_LOG_LENGTH: int = 50
+
+
+class Reranker:
+ """Cross-encoder reranker for improved retrieval result relevance.
+
+ This class implements a reranking component that uses a cross-encoder
+ model to rescore retrieval results. Cross-encoders provide more accurate
+ relevance judgments than bi-encoders because they process the query and
+ document together, capturing their semantic interaction.
+
+ The reranker is intended as an optional second-stage ranker:
+ 1. Initial retrieval (bi-encoder): Fast, retrieves candidate set
+ 2. Reranking (cross-encoder): Slower, improves ordering accuracy
+
+ Key Features:
+ - Lazy model loading: Model loaded on first rerank() call
+ - CPU-only execution: No GPU required by default
+ - Score normalization: Outputs consistent [0, 1] scores
+ - Latency logging: Monitors reranking performance
+
+ Lazy Loading Pattern:
+ The CrossEncoder model is NOT loaded when the Reranker is instantiated.
+ Instead, it is loaded on the first call to rerank(). This ensures:
+ - Fast module import (no torch/sentence-transformers at import)
+ - Memory efficiency (model only loaded when needed)
+ - Graceful degradation if dependencies are missing
+
+ Score Normalization:
+ Cross-encoder models output raw logits which can be any real number.
+ We normalize these using sigmoid: normalized = 1 / (1 + exp(-score))
+ This ensures scores are in [0, 1] range, consistent with other
+ retrieval scores in the pipeline.
+
+ Attributes:
+ ----------
+ model_name : str
+ HuggingFace model identifier for the cross-encoder.
+
+ device : str
+ Device for inference ('cpu' or 'cuda').
+
+ Example:
+ -------
+ >>> reranker = Reranker() # Model not loaded yet
+ >>> reranker.model_loaded
+ False
+ >>> results = retriever.retrieve("thermal comfort", top_k=20)
+ >>> reranked = reranker.rerank("thermal comfort", results, top_k=5)
+ >>> reranker.model_loaded
+ True
+ >>> len(reranked)
+ 5
+
+ Note:
+ ----
+ The reranker replaces the original retrieval scores with cross-encoder
+ scores. If you need to preserve original scores, make a copy of the
+ results before reranking.
+
+ """
+
+ def __init__(
+ self,
+ model_name: str = DEFAULT_RERANKER_MODEL,
+ device: str = DEFAULT_DEVICE,
+ ) -> None:
+ """Initialize the reranker with lazy model loading.
+
+ Creates a Reranker instance configured with the specified model and
+ device. The actual model is NOT loaded during initialization - it is
+ loaded lazily on the first call to rerank().
+
+ Args:
+ ----
+ model_name: HuggingFace model identifier for the cross-encoder.
+ Defaults to "BAAI/bge-reranker-base", a strong general-purpose
+ reranker with good accuracy/latency tradeoff.
+ Other options include:
+ - "BAAI/bge-reranker-large": More accurate but slower
+ - "cross-encoder/ms-marco-MiniLM-L-6-v2": Faster but less accurate
+ device: Device for model inference. Defaults to "cpu" for
+ compatibility. Use "cuda" for GPU acceleration if available.
+
+ Example:
+ -------
+ >>> # Default configuration (CPU, bge-reranker-base)
+ >>> reranker = Reranker()
+
+ >>> # Custom model on GPU
+ >>> reranker = Reranker(
+ ... model_name="BAAI/bge-reranker-large",
+ ... device="cuda"
+ ... )
+
+ Note:
+ ----
+ The model is NOT loaded during __init__. Check model_loaded
+ property to see if the model has been loaded.
+
+ """
+ # =================================================================
+ # Store configuration
+ # =================================================================
+ self._model_name: str = model_name
+ self._device: str = device
+
+ # =================================================================
+ # Model placeholder (lazy loaded)
+ # =================================================================
+ # Using None as sentinel for lazy initialization
+ # The actual CrossEncoder is loaded on first rerank() call
+ # =================================================================
+ self._model: CrossEncoder | None = None
+
+ logger.debug(
+ "Initialized Reranker with model=%r, device=%r (not yet loaded)",
+ self._model_name,
+ self._device,
+ )
+
+ # -------------------------------------------------------------------------
+ # Private Methods
+ # -------------------------------------------------------------------------
+
+ def _ensure_model_loaded(self) -> CrossEncoder:
+ """Load the cross-encoder model if not already loaded.
+
+ This method implements lazy loading for the CrossEncoder model.
+ If the model was already loaded, it returns the cached instance.
+ Otherwise, it imports sentence_transformers and creates the model.
+
+ Returns:
+ -------
+ The CrossEncoder model instance to use for reranking.
+
+ Note:
+ ----
+ The model is cached after first creation, so subsequent calls
+ return the same instance without reloading.
+
+ This method logs the loading time for performance monitoring.
+
+ """
+ # Return existing model if available
+ if self._model is not None:
+ return self._model
+
+ # =================================================================
+ # Lazy import and create model
+ # =================================================================
+ # Import CrossEncoder here to avoid loading torch and
+ # sentence-transformers at module import time
+ # =================================================================
+ logger.info(
+ "Lazy loading CrossEncoder model: %r on device: %r",
+ self._model_name,
+ self._device,
+ )
+
+ # Measure model loading time
+ load_start = time.perf_counter()
+
+ from sentence_transformers import CrossEncoder
+
+ # Create and cache the model
+ # max_length is set to handle typical chunk sizes efficiently
+ self._model = CrossEncoder(
+ self._model_name,
+ device=self._device,
+ )
+
+ load_elapsed_ms = (time.perf_counter() - load_start) * 1000
+
+ logger.info(
+ "CrossEncoder model loaded in %.2f ms",
+ load_elapsed_ms,
+ )
+
+ return self._model
+
+ def _normalize_score(self, raw_score: float) -> float:
+ """Normalize a cross-encoder score to [0, 1] range using sigmoid.
+
+ Cross-encoder models output raw logits (any real number). We normalize
+ these to [0, 1] using the sigmoid function for consistency with other
+ retrieval scores in the pipeline.
+
+ Sigmoid Formula:
+ normalized = 1 / (1 + exp(-score))
+
+ Score Mapping:
+ - Large positive (e.g., +5) -> ~0.993 (high relevance)
+ - Zero -> 0.5 (neutral)
+ - Large negative (e.g., -5) -> ~0.007 (low relevance)
+
+ Args:
+ ----
+ raw_score: Raw logit score from cross-encoder.
+ Can be any real number.
+
+ Returns:
+ -------
+ Normalized score in [0, 1] range.
+
+ Example:
+ -------
+ >>> reranker._normalize_score(2.0)
+ 0.8807970779778823
+ >>> reranker._normalize_score(0.0)
+ 0.5
+ >>> reranker._normalize_score(-2.0)
+ 0.11920292202211755
+
+ Note:
+ ----
+ Sigmoid is chosen because:
+ - It naturally maps any real number to (0, 1)
+ - It preserves ordering (monotonically increasing)
+ - It provides smooth gradation of confidence
+
+ """
+ # Sigmoid function: 1 / (1 + exp(-x))
+ # Using math.exp for single value computation (more efficient than numpy)
+ try:
+ return 1.0 / (1.0 + math.exp(-raw_score))
+ except OverflowError:
+ # Handle extreme negative values where exp(-x) overflows
+ # If raw_score is very negative, sigmoid approaches 0
+ # If raw_score is very positive, we won't get overflow
+ return 0.0
+
+ # -------------------------------------------------------------------------
+ # Public Methods
+ # -------------------------------------------------------------------------
+
+ def rerank(
+ self,
+ query: str,
+ results: list[RetrievalResult],
+ top_k: int | None = None,
+ ) -> list[RetrievalResult]:
+ """Rerank retrieval results using cross-encoder scores.
+
+ Takes a list of retrieval results and reranks them using a cross-encoder
+ model. The cross-encoder scores query-document pairs jointly, providing
+ more accurate relevance judgments than the original bi-encoder scores.
+
+ Processing Steps:
+ 1. Handle edge cases (empty list, single result)
+ 2. Load cross-encoder model if not already loaded (lazy)
+ 3. Create query-document pairs for scoring
+ 4. Score all pairs with cross-encoder
+ 5. Normalize scores to [0, 1] using sigmoid
+ 6. Create new RetrievalResult objects with updated scores
+ 7. Sort by new scores descending
+ 8. Return top_k results (or all if top_k is None)
+
+ Args:
+ ----
+ query: The search query used for retrieval.
+ This is paired with each result's text for cross-encoder scoring.
+ results: List of RetrievalResult objects to rerank.
+ These typically come from initial bi-encoder retrieval.
+ top_k: If provided, return only the top_k results after reranking.
+ If None, all results are returned (just reordered).
+ Must be positive if provided.
+
+ Returns:
+ -------
+ List of RetrievalResult objects sorted by cross-encoder score
+ in descending order (highest relevance first). Each result has
+ its score replaced with the normalized cross-encoder score.
+ Special cases:
+ - Empty list if input is empty
+ - Single result with updated score if only one input
+ - Reranked list otherwise
+
+ Raises:
+ ------
+ ValueError: If query is empty or top_k is not positive.
+
+ Example:
+ -------
+ >>> results = retriever.retrieve("What is PMV?", top_k=20)
+ >>> # Rerank and get top 5
+ >>> reranked = reranker.rerank("What is PMV?", results, top_k=5)
+ >>> len(reranked)
+ 5
+ >>> # Scores are now cross-encoder scores
+ >>> reranked[0].score # Highest cross-encoder score
+ 0.95
+
+ Note:
+ ----
+ - The original scores are replaced with cross-encoder scores
+ - Results maintain all other metadata (chunk_id, text, etc.)
+ - Latency is logged for performance monitoring
+
+ """
+ # =================================================================
+ # Step 1: Validate input parameters
+ # =================================================================
+ if not query or not query.strip():
+ msg = "query cannot be empty"
+ raise ValueError(msg)
+
+ if top_k is not None and top_k <= 0:
+ msg = f"top_k must be positive if provided, got {top_k}"
+ raise ValueError(msg)
+
+ # =================================================================
+ # Step 2: Handle edge cases
+ # =================================================================
+
+ # Empty results - nothing to rerank
+ if not results:
+ logger.debug("No results to rerank, returning empty list")
+ return []
+
+ # Single result - no reranking needed, but we still score it
+ # for consistency (user expects cross-encoder score)
+ if len(results) == 1:
+ logger.debug("Single result, scoring without reordering")
+ # Still need to score for consistency
+ # Fall through to normal processing
+
+ num_results = len(results)
+ query_preview = (
+ query[:_MAX_QUERY_LOG_LENGTH] + "..."
+ if len(query) > _MAX_QUERY_LOG_LENGTH
+ else query
+ )
+ logger.debug(
+ "Reranking %d results for query: %r",
+ num_results,
+ query_preview,
+ )
+
+ # =================================================================
+ # Step 3: Ensure model is loaded (lazy loading)
+ # =================================================================
+ model = self._ensure_model_loaded()
+
+ # =================================================================
+ # Step 4: Create query-document pairs for scoring
+ # =================================================================
+ # Cross-encoders take (query, document) pairs as input
+ # We create a pair for each result's text
+ # =================================================================
+ pairs: list[tuple[str, str]] = [(query, result.text) for result in results]
+
+ # =================================================================
+ # Step 5: Score all pairs with cross-encoder
+ # =================================================================
+ # Measure reranking latency
+ rerank_start = time.perf_counter()
+
+ # predict() returns array of scores
+ raw_scores = model.predict(pairs)
+
+ rerank_elapsed_ms = (time.perf_counter() - rerank_start) * 1000
+
+ logger.info(
+ "Reranked %d results in %.2f ms (%.2f ms/result)",
+ num_results,
+ rerank_elapsed_ms,
+ rerank_elapsed_ms / num_results if num_results > 0 else 0,
+ )
+
+ # =================================================================
+ # Step 6: Normalize scores and create result objects
+ # =================================================================
+ # Create new RetrievalResult objects with cross-encoder scores
+ # We create new objects because RetrievalResult is frozen (immutable)
+ # =================================================================
+ scored_results: list[tuple[float, RetrievalResult]] = []
+
+ for result, raw_score in zip(results, raw_scores, strict=True):
+ # Normalize score to [0, 1] using sigmoid
+ normalized_score = self._normalize_score(float(raw_score))
+
+ # Create new RetrievalResult with updated score
+ # All other fields are copied from original
+ new_result = RetrievalResult(
+ chunk_id=result.chunk_id,
+ text=result.text,
+ score=normalized_score,
+ heading_path=list(result.heading_path), # Copy list
+ source=result.source,
+ page=result.page,
+ )
+
+ scored_results.append((normalized_score, new_result))
+
+ # =================================================================
+ # Step 7: Sort by score descending
+ # =================================================================
+ # Sort by score (first element of tuple) in descending order
+ scored_results.sort(key=lambda x: x[0], reverse=True)
+
+ # Extract just the results (without scores tuple)
+ reranked: list[RetrievalResult] = [r for _, r in scored_results]
+
+ # =================================================================
+ # Step 8: Apply top_k limit if specified
+ # =================================================================
+ if top_k is not None and top_k < len(reranked):
+ reranked = reranked[:top_k]
+ logger.debug("Limited results to top_k=%d", top_k)
+
+ # Log score distribution for debugging
+ if reranked:
+ top_score = reranked[0].score
+ bottom_score = reranked[-1].score
+ logger.debug(
+ "Reranked score range: [%.4f, %.4f]",
+ bottom_score,
+ top_score,
+ )
+
+ return reranked
+
+ # -------------------------------------------------------------------------
+ # Properties
+ # -------------------------------------------------------------------------
+
+ @property
+ def model_loaded(self) -> bool:
+ """Check if the cross-encoder model has been loaded.
+
+ Returns True if the model has been loaded (either through explicit
+ rerank() call or internal initialization), False if the model is
+ still pending lazy initialization.
+
+ Returns:
+ -------
+ True if model is loaded, False otherwise.
+
+ Example:
+ -------
+ >>> reranker = Reranker()
+ >>> reranker.model_loaded
+ False
+ >>> _ = reranker.rerank("query", results)
+ >>> reranker.model_loaded
+ True
+
+ """
+ return self._model is not None
+
+ @property
+ def model_name(self) -> str:
+ """Get the model name/identifier.
+
+ Returns
+ -------
+ HuggingFace model identifier string.
+
+ """
+ return self._model_name
+
+ @property
+ def device(self) -> str:
+ """Get the device used for inference.
+
+ Returns
+ -------
+ Device string ('cpu' or 'cuda').
+
+ """
+ return self._device