diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 0000000..08c144d
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,118 @@
+# Environment Variables for Monadic DNA Explorer
+# Copy this file to .env.local and fill in your values
+
+# =============================================================================
+# NILLION API - Required for AI features (nilAI provider)
+# =============================================================================
+# Get your API key from: https://nillion.com
+# NOTE: This is only needed for the nilAI provider. Ollama and HuggingFace
+# providers are configured in the UI (Menu Bar > AI Settings).
+NILLION_API_KEY=your_api_key_here
+
+# =============================================================================
+# DATABASE - PostgreSQL (optional, for vector similarity search)
+# =============================================================================
+# If not provided, app will fall back to in-memory storage
+# POSTGRES_URL=postgresql://user:password@localhost:5432/gwasifier
+
+# =============================================================================
+# AUTHENTICATION - Dynamic.xyz (for wallet connection)
+# =============================================================================
+NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id_here
+
+# =============================================================================
+# BLOCKCHAIN PAYMENTS - Alchemy indexer API
+# =============================================================================
+# Get your API key from: https://www.alchemy.com/
+ALCHEMY_API_KEY=your_alchemy_api_key
+
+# EVM payment wallet address (same address used across all EVM chains)
+# This is where users send their stablecoin payments on Ethereum, Base, Arbitrum, Optimism, Polygon
+# NEXT_PUBLIC_ prefix is required because it's displayed in the browser UI
+# Format: 0x... (42 characters, EVM address)
+# Note: For future non-EVM chains (Solana, Monad, etc.), add separate variables like:
+# NEXT_PUBLIC_SOLANA_PAYMENT_WALLET_ADDRESS, NEXT_PUBLIC_MONAD_PAYMENT_WALLET_ADDRESS
+NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS=0x0000000000000000000000000000000000000000
+
+# Stablecoin contract addresses (standard addresses, don't change unless deploying new token)
+
+# USDC contracts
+USDC_CONTRACT_ETHEREUM=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
+USDC_CONTRACT_BASE=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
+USDC_CONTRACT_ARBITRUM=0xaf88d065e77c8cC2239327C5EDb3A432268e5831
+USDC_CONTRACT_OPTIMISM=0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85
+USDC_CONTRACT_POLYGON=0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359
+
+NEXT_PUBLIC_USDC_CONTRACT_ETHEREUM=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
+NEXT_PUBLIC_USDC_CONTRACT_BASE=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
+NEXT_PUBLIC_USDC_CONTRACT_ARBITRUM=0xaf88d065e77c8cC2239327C5EDb3A432268e5831
+NEXT_PUBLIC_USDC_CONTRACT_OPTIMISM=0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85
+NEXT_PUBLIC_USDC_CONTRACT_POLYGON=0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359
+
+# USDT contracts
+USDT_CONTRACT_ETHEREUM=0xdAC17F958D2ee523a2206206994597C13D831ec7
+USDT_CONTRACT_BASE=0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2
+USDT_CONTRACT_ARBITRUM=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9
+USDT_CONTRACT_OPTIMISM=0x94b008aA00579c1307B0EF2c499aD98a8ce58e58
+USDT_CONTRACT_POLYGON=0xc2132D05D31c914a87C6611C10748AEb04B58e8F
+
+NEXT_PUBLIC_USDT_CONTRACT_ETHEREUM=0xdAC17F958D2ee523a2206206994597C13D831ec7
+NEXT_PUBLIC_USDT_CONTRACT_BASE=0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2
+NEXT_PUBLIC_USDT_CONTRACT_ARBITRUM=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9
+NEXT_PUBLIC_USDT_CONTRACT_OPTIMISM=0x94b008aA00579c1307B0EF2c499aD98a8ce58e58
+NEXT_PUBLIC_USDT_CONTRACT_POLYGON=0xc2132D05D31c914a87C6611C10748AEb04B58e8F
+
+# DAI contracts
+DAI_CONTRACT_ETHEREUM=0x6B175474E89094C44Da98b954EedeAC495271d0F
+DAI_CONTRACT_BASE=0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb
+DAI_CONTRACT_ARBITRUM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1
+DAI_CONTRACT_OPTIMISM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1
+DAI_CONTRACT_POLYGON=0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063
+
+NEXT_PUBLIC_DAI_CONTRACT_ETHEREUM=0x6B175474E89094C44Da98b954EedeAC495271d0F
+NEXT_PUBLIC_DAI_CONTRACT_BASE=0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb
+NEXT_PUBLIC_DAI_CONTRACT_ARBITRUM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1
+NEXT_PUBLIC_DAI_CONTRACT_OPTIMISM=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1
+NEXT_PUBLIC_DAI_CONTRACT_POLYGON=0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063
+
+# =============================================================================
+# STRIPE PAYMENTS - Credit/Debit Card Payments
+# =============================================================================
+# Get your API keys from: https://dashboard.stripe.com/apikeys
+# Use test keys for development (sk_test_..., pk_test_...)
+# Use live keys for production (sk_live_..., pk_live_...)
+
+# Server-side secret key (NEVER expose in client code)
+STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
+
+# Client-side publishable key (safe to expose in browser)
+NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
+
+# Stripe price ID for monthly subscription ($4.99/month recurring)
+# Get this from: https://dashboard.stripe.com/products (click your product → copy Price ID)
+# Format: price_... (NOT prod_...)
+STRIPE_PRICE_ID=price_your_price_id_here
+
+# NOTE: Subscriptions are verified via direct Stripe API queries
+# No webhook configuration or database storage required
+
+# =============================================================================
+# STABLECOIN PRICING
+# =============================================================================
+# All stablecoins (USDC, USDT, DAI) are assumed to be pegged 1:1 with USD
+# No historical price lookups needed
+
+# =============================================================================
+# SUBSCRIPTION SETTINGS
+# =============================================================================
+# Subscription price (used for calculating days purchased)
+MONTHLY_SUBSCRIPTION_PRICE=4.99
+
+# Subscription cache duration in hours (default: 1 hour)
+# How long to cache subscription status in localStorage before re-checking blockchain
+NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS=1
+
+# =============================================================================
+# ANALYTICS (optional)
+# =============================================================================
+# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=your_domain.com
diff --git a/.gitignore b/.gitignore
index e208c04..c7bf4f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,6 @@ yarn-error.log*
/.idea/
/localdata/gwas_catalog.sqlite
/localdata/gwas_catalog_v1.0.2-associations_e115_r2025-09-15.tsv
+/localdata/gwas_catalog_v1.0.2-associations_e115_r2025-09-15.tsv.gz
+/venv/
+/embeddings_backup.npz
diff --git a/README.md b/README.md
index c3a2ec6..394104c 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,30 @@
# Monadic DNA Explorer
-Match your DNA data against an open ended catalogue of DNA traits with private AI-powered analysis
+Match your DNA data against an open ended catalogue of DNA traits with private LLM-powered analysis
**🔗 Repository:** [github.com/Monadic-DNA/Explorer](https://github.com/Monadic-DNA/Explorer)
+## Table of Contents
+
+- [Features](#features)
+- [Development](#development)
+ - [Quick Start](#running-the-monadic-dna-explorer)
+ - [Environment Variables](#environment-variables)
+- [Production Deployment](#production-deployment)
+- [Semantic Search Setup](#semantic-search-setup)
+- [Premium Features & Payments](#premium-features--payments)
+- [License](#license)
+
## Features
-- Interactive exploration of GWAS Catalog studies with quality-aware filtering
-- Upload and analyze your personal genetic data (23andMe, AncestryDNA, Monadic DNA)
-- Private AI analysis powered by Nillion's nilAI - your data is processed in a Trusted Execution Environment
-- Save and export your results
+- **Semantic Search**: LLM-powered semantic search understands the meaning of your queries (e.g., "memory loss" finds "cognitive decline" studies)
+- **Interactive exploration** of GWAS Catalog studies with quality-aware filtering
+- **Upload and analyze** your personal genetic data (23andMe, AncestryDNA, Monadic DNA)
+- **Private LLM analysis** powered by Nillion's nilAI, your data is processed in a Trusted Execution Environment
+- **Premium Features**: LLM-powered genetic analysis chat, Run All analysis, comprehensive reports
+- **Dual payment system**: Credit/debit cards (Stripe) or stablecoins (USDC/USDT/DAI on Ethereum, Base, Arbitrum, Optimism, Polygon)
+- **Save and export** your results
+- **Privacy-focused**: All processing happens on your infrastructure (no third-party APIs for search)
## Development
@@ -32,6 +47,47 @@ npm run dev
The development server defaults to http://localhost:3000. You can override the database location by exporting `GWAS_DB_PATH` before starting the server.
+### Dev Mode Auto-Loading (Development Only)
+
+When running `npm run dev` on localhost, the app automatically enables dev mode to speed up your development workflow:
+
+**What Auto-Loads:**
+- **Genotype file** - After uploading once, auto-loads on next session
+- **Results file** - After loading/exporting once, auto-loads on next session
+- **Personalization password** - Auto-unlocks encrypted personal data
+
+**How It Works:**
+
+1. **Chrome/Edge (Full Auto-Load)**:
+ - Uses File System Access API to store persistent file handles in IndexedDB
+ - Files load automatically with zero interaction
+ - Password stored in IndexedDB to auto-unlock personalization
+
+2. **Brave/Firefox (Fallback Mode)**:
+ - Brave disables File System Access API by default for privacy
+ - File pickers appear automatically on app load
+ - Just select your files - still faster than manual navigation
+ - Password auto-unlock works in all browsers
+
+**First-Time Setup:**
+```bash
+npm run dev
+# 1. Upload your genotype file (saves handle/marker)
+# 2. Load or export results (saves handle/marker)
+# 3. Set up personalization (saves password)
+# Next load: Everything restores automatically!
+```
+
+**Security Note:**
+- Dev mode ONLY activates when `NODE_ENV==='development'` AND `hostname==='localhost'`
+- Password stored in plain text in IndexedDB (local only, never sent to server)
+- Clear dev data: `indexedDB.deleteDatabase('gwasifier_dev_mode')` in browser console
+
+**Enable Full Auto-Load in Brave:**
+1. Open `brave://settings/`
+2. **Privacy and security** → **Site and Shields Settings** → **File System Access**
+3. Add exception for `http://localhost:3000`
+
## Production Deployment
### Using PostgreSQL in Production
@@ -56,27 +112,336 @@ npm start
### Environment Variables
+**GWAS Database (Required):**
- `POSTGRES_DB`: PostgreSQL connection string (if set, takes precedence over SQLite)
- `GWAS_DB_PATH`: Path to SQLite database file (only used if `POSTGRES_DB` is not set)
-- `NILLION_API_KEY`: (Optional) API key for Nillion's nilAI to enable private AI analysis of results
+
+**LLM Features:**
+- **LLM Provider Selection**: Configure in the UI (Menu Bar > LLM Settings button)
+ - **Nillion nilAI** (Default): Privacy-preserving LLM in Trusted Execution Environment
+ - Requires `NILLION_API_KEY` environment variable
+ - **Ollama** (Local): Run LLM models on your own machine
+ - Requires Ollama installation with gpt-oss-20b model
+ - Configure address and port in UI settings
+ - **HuggingFace** (Cloud): Cloud-based LLM via HuggingFace Router
+ - Configure API key directly in UI settings (stored in browser localStorage)
+- **Privacy**: All providers send data directly from browser to LLM service - never through our servers
+
+**Authentication (Required for Premium):**
+- `NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID`: Dynamic.xyz environment ID for logging in
+
+**Blockchain Payments (Required for Premium):**
+- `ALCHEMY_API_KEY`: Alchemy API key for blockchain indexer queries
+- `NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS`: EVM wallet address where users send ETH/USDC payments
+- `NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS`: Cache duration in hours (default: 1)
+
+See `.env.local.example` for complete configuration details.
### Database Schema
-The application expects a table named `gwas_catalog` with the standard GWAS Catalog schema. The PostgreSQL version should mirror the SQLite schema structure.
+Complete database schemas are provided in the `sql/` directory:
-### Performance Optimization
+- **PostgreSQL**: `sql/postgres_schema.sql` - Production schema with pgvector support
+- **SQLite**: `sql/sqlite_schema.sql` - Development schema
-For optimal performance with large GWAS datasets, apply the provided database indexes:
+Both schemas include:
+- `gwas_catalog` table with auto-incrementing `id` primary key
+- `study_embeddings` table with foreign key to `gwas_catalog.id`
+- `embedding_cache` table for query caching
+- All necessary indexes including HNSW for PostgreSQL
+
+To initialize a fresh database:
```bash
+# For PostgreSQL
+psql $POSTGRES_DB < sql/postgres_schema.sql
+
# For SQLite
-sqlite3 /path/to/gwas_catalog.sqlite < sql/sqlite_indexes.sql
+sqlite3 /path/to/gwas_catalog.sqlite < sql/sqlite_schema.sql
+```
-# For PostgreSQL
-psql $POSTGRES_DB < sql/postgresql_indexes.sql
+**Architecture benefits:**
+- Simple integer foreign key JOINs (faster than string operations)
+- Foreign key constraints ensure data integrity
+- No redundant lookup tables needed
+- Reduced storage and improved query performance
+
+## Semantic Search Setup
+
+The application includes LLM-powered semantic search that understands the meaning of queries, not just keywords.
+
+### Prerequisites
+
+1. **PostgreSQL with pgvector** (production) or **SQLite** (development)
+2. **Python 3.8+** with GPU support (for initial embedding generation)
+
+The complete database schema (including semantic search support) is in `sql/postgres_schema.sql` or `sql/sqlite_schema.sql`. See [Database Schema](#database-schema) section above for setup instructions.
+
+**Note for PostgreSQL:** The `pgvector` extension is automatically enabled by the schema. Most managed PostgreSQL services (DigitalOcean, AWS RDS, etc.) allow extension creation by database owners.
+
+### Step 1: Generate Study Embeddings
+
+Use your local GPU to generate embeddings for all studies:
+
+```bash
+# Install Python dependencies
+pip install -r scripts/requirements.txt
+
+# For PostgreSQL (production) - save local backup
+POSTGRES_DB="postgresql://..." python scripts/generate-embeddings.py --save-local embeddings_backup.npz
+
+# For SQLite (development)
+python scripts/generate-embeddings.py
+
+# Load from local backup to new database (no GPU needed)
+POSTGRES_DB="postgresql://..." python scripts/generate-embeddings.py --load-local embeddings_backup.npz
+
+# Optional: Limit for testing
+python scripts/generate-embeddings.py --limit 1000
+
+# Adjust batch size based on GPU VRAM
+python scripts/generate-embeddings.py --batch-size 256 # Default: 512
+```
+
+**Time estimate**: 20-60 minutes for 1M studies on a modern GPU (RTX 3080/4090)
+
+**Local backup benefits**:
+- Reuse embeddings for multiple databases
+- No need to regenerate on database migration
+- Transfer embeddings between environments
+- Backup file size: ~500 MB compressed (for 1M studies, 512 dims)
+
+The script uses **nomic-embed-text-v1.5** with:
+- **512 dimensions** (33% storage savings, 0.5% quality loss vs 768)
+- **Matryoshka representation learning** (efficient truncation)
+- **Task-specific prefixes** (`search_document:` for studies)
+
+### Step 2: Deploy Application
+
+The application automatically generates query embeddings on-the-fly using Transformers.js.
+
+**For DigitalOcean App Platform (Node.js Buildpack):**
+
+1. **Configure deployment** using the provided `.do/app.yaml`:
+ - Node.js buildpack (auto-detected)
+ - Health check with 60s timeout (allows model download)
+ - Professional-XS instance (1 vCPU, 1 GB RAM)
+
+2. **Set environment variables** in DO dashboard:
+ - `POSTGRES_DB`: Your PostgreSQL connection string
+
+3. **Deploy** - push to GitHub or deploy via DO CLI
+
+4. **First deployment**: Health check downloads model (~30-50s) before routing traffic
+
+5. **Subsequent deployments**: Model re-downloads on each deploy (health check handles it)
+
+**Cold start behavior:**
+- First request after deploy: 10-20s (health check pre-warms model)
+- Subsequent requests: <100ms (model already loaded)
+- Model downloads: ~137 MB per deployment (cached in `/tmp/.transformers-cache`)
+
+**Alternative: Docker Deployment (Optional)**
+
+For faster cold starts and no repeated downloads, use the provided `Dockerfile`:
+
+```yaml
+# .do/app.yaml
+services:
+ - name: web
+ dockerfile_path: Dockerfile # Switch to Docker
+```
+
+Benefits:
+- Cold start: 10-20s (model pre-baked in image)
+- Model downloaded once during build (not per deploy)
+- Larger image size: +137 MB
+
+**For local development:**
+
+```bash
+npm install
+npm run dev
+```
+
+The model downloads automatically on first search (~137 MB, cached in `.transformers-cache/`).
+
+### Step 3: Test Semantic Search
+
+Try these queries to see semantic search in action:
+
+- **"memory loss"** → finds studies about "cognitive decline", "dementia", "Alzheimer's"
+- **"heart attack"** → finds "myocardial infarction", "coronary artery disease"
+- **"diabetes risk"** → finds "type 2 diabetes", "insulin resistance", "hyperglycemia"
+
+**API Usage:**
+
+```bash
+# Semantic search (default)
+curl "http://localhost:3000/api/studies?search=alzheimer%20risk"
+
+# Keyword search (fallback)
+curl "http://localhost:3000/api/studies?search=alzheimer%20risk&semantic=false"
+```
+
+### Architecture
+
+**Two-tier caching for fast queries:**
+
+1. **Memory cache** (100 hot queries): <1ms
+2. **PostgreSQL cache** (10K warm queries): 2-5ms
+3. **Generation** (cache miss): 50-100ms
+
+**Query flow:**
+```
+User query → Check memory cache → Check DB cache → Generate embedding →
+ pgvector similarity search → Filter + rank → Return results
+```
+
+**Storage requirements:**
+- Study embeddings: ~2 KB per study (512 dims × 4 bytes)
+- 1M studies: ~2 GB embeddings + ~4 GB HNSW index = 6 GB total
+- Query cache: ~2 KB per cached query (~20 MB for 10K queries)
+- Compared to old architecture: Saves ~105 MB by eliminating redundant lookup table
+
+### Privacy & Security
+
+- ✅ **No third-party APIs**: All embedding generation happens on your infrastructure
+- ✅ **Self-hosted models**: Uses open-source nomic-embed-text-v1.5
+- ✅ **Query privacy**: Search queries never leave the servers
+- ✅ **Cache encryption**: Database cache uses standard PostgreSQL security
+- ✅ **Ephemeral processing**: Query embeddings computed transiently (not logged)
+
+### Monitoring & Maintenance
+
+**Check embedding service status:**
+```bash
+curl http://localhost:3000/api/health
+```
+
+**Monitor cache performance:**
+```sql
+-- PostgreSQL
+SELECT
+ COUNT(*) as total_queries,
+ AVG(access_count) as avg_accesses,
+ MAX(access_count) as most_popular_count
+FROM embedding_cache;
+
+-- Top 20 most popular queries
+SELECT query, access_count, accessed_at
+FROM embedding_cache
+ORDER BY access_count DESC
+LIMIT 20;
+```
+
+**Clean up old cache entries** (run periodically):
+```bash
+# Via API (requires auth)
+curl -X POST http://localhost:3000/api/admin/cache-cleanup \
+ -H "Authorization: Bearer $ADMIN_SECRET"
+
+# Or manually in PostgreSQL
+DELETE FROM embedding_cache
+WHERE accessed_at < NOW() - INTERVAL '90 days'
+ OR id IN (
+ SELECT id FROM embedding_cache
+ ORDER BY accessed_at ASC
+ LIMIT (SELECT COUNT(*) - 10000 FROM embedding_cache)
+ );
+```
+
+### Troubleshooting
+
+**Slow first search after deployment:**
+- Model is loading (~5-10s). Health check at `/api/health` warms it up automatically.
+
+**"Vector dimension mismatch" errors:**
+- Ensure you used `--dimensions 512` when generating embeddings
+- Check migration created `vector(512)` column (not `vector(768)`)
+
+**Embeddings not found:**
+- Verify schema applied: `\d study_embeddings` should show table exists
+- Check embeddings generated: `SELECT COUNT(*) FROM study_embeddings;`
+
+**Poor search quality:**
+- Semantic search only works with PostgreSQL + pgvector (SQLite falls back to keyword search)
+- Ensure HNSW index created: `\d+ study_embeddings` should show `idx_study_embeddings_embedding`
+
+## Premium Features & Payments
+
+GWASifier offers premium features including LLM-powered genetic analysis chat, Run All analysis, and comprehensive reports.
+
+### Dual Payment System
+
+The app supports **two payment methods** for premium subscriptions:
+
+#### 1. Credit/Debit Card Payments (Stripe) - Recurring Subscription
+- **Fixed $4.99/month** - Standard recurring subscription
+- **Auto-renewal** - Automatically renews monthly
+- **Instant activation** - Subscription activates immediately
+- **Managed via Stripe** - Cancel anytime through customer portal
+- **Setup**: See `STRIPE_INTEGRATION.md` for detailed instructions
+
+#### 2. Stablecoin Payments (Blockchain) - Flexible Prepaid
+- **Flexible amounts** - Pay any amount ($1+ USD equivalent)
+- **One-time payment** - No auto-renewal, top up when needed
+- **Supported chains**: Ethereum, Base (recommended), Arbitrum, Optimism
+- **Accepted tokens**: ETH and USDC
+- **Examples**: $4.99 = 30 days, $10 = 60 days, $50 = 300 days
+- **Setup**: See `STABLECOIN_PAYMENTS.md` for detailed instructions
+
+**Key Difference:**
+- **Stripe**: Fixed recurring subscription ($4.99/month, auto-renews)
+- **Stablecoin**: Flexible prepaid (choose your amount, no auto-renewal)
+
+**Payment Stacking:**
+Users can combine both! Subscribe with card for recurring billing, then add extra months with stablecoin payments as needed.
+
+**See `PAYMENT_METHODS.md` for detailed comparison.**
+
+### How It Works
+
+**Stripe Card Payments (Recurring):**
+1. User logs in via Dynamic.xyz (for identity)
+2. User selects "Pay with Card" (fixed $4.99/month)
+3. Redirected to Stripe Checkout for secure payment
+4. Subscription created, payments recorded in PostgreSQL
+5. Auto-renews monthly, cancel anytime via Stripe portal
+
+**Blockchain Stablecoin Payments:**
+1. User logs in via Dynamic.xyz
+2. User sends ETH or USDC to payment wallet from connected wallet
+3. App queries Alchemy indexer to find all payments from user's wallet
+4. App uses Alchemy Prices API to get historical prices at transaction time
+5. App calculates subscription: `days = (amountUSD / 4.99) * 30`
+
+**Combined Subscription:**
+- Both payment sources are checked and combined
+- Total days = blockchain days + Stripe days
+- Subscription status cached in localStorage for 1 hour
+
+### Setup
+
+See detailed setup guides:
+- **Stripe Payments**: `STRIPE_INTEGRATION.md`
+- **Stablecoin Payments**: `STABLECOIN_PAYMENTS.md`
+
+**Quick Start:**
+```bash
+# Set required environment variables in .env.local
+ALCHEMY_API_KEY=your_alchemy_api_key
+NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS=0xYourWalletAddress
+NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id
```
-These indexes can improve query performance by 10-100x for search operations. See `sql/README.md` for detailed instructions.
+### Benefits
+
+- ✅ Zero infrastructure costs (no database)
+- ✅ No personal data storage (GDPR-friendly)
+- ✅ Transparent (all payments verifiable on-chain)
+- ✅ Flexible (users can top up anytime)
+- ✅ Free tier API usage sufficient for 5,000+ daily active users
## License
diff --git a/STABLECOIN_PAYMENTS.md b/STABLECOIN_PAYMENTS.md
new file mode 100644
index 0000000..4c608d6
--- /dev/null
+++ b/STABLECOIN_PAYMENTS.md
@@ -0,0 +1,255 @@
+# Stablecoin Payment System (EVM Chains)
+
+## Overview
+
+GWASifier Premium now uses a **database-free, stablecoin-based payment system**. Users pay with stablecoins (USDC, USDT, or DAI) from their connected wallet, and subscription status is verified on-chain using Alchemy's indexer API.
+
+## How It Works
+
+### User Flow
+1. User connects wallet via Dynamic.xyz
+2. User navigates to Premium tab
+3. User selects stablecoin (USDC, USDT, or DAI) and sends payment to payment wallet
+4. After blockchain confirmation (~1-2 minutes), subscription activates automatically
+5. Subscription status is cached in localStorage for 1 hour
+
+### Technical Architecture
+
+```
+┌─────────────┐
+│ User │ Connects wallet (Dynamic.xyz)
+│ Wallet │ Sends stablecoin (USDC/USDT/DAI) to payment wallet
+└──────┬──────┘
+ │
+ ▼
+┌─────────────┐
+│ Blockchain │ Ethereum, Base, Arbitrum, Optimism, Polygon
+│ Transaction │ Stablecoin transfer
+└──────┬──────┘
+ │
+ ▼
+┌─────────────┐
+│ Alchemy │ Indexer API queries transaction history
+│ Indexer │ getAssetTransfers(from: userWallet, to: paymentWallet)
+└──────┬──────┘
+ │
+ ▼
+┌─────────────┐
+│Subscription │ Calculate total days purchased
+│ Calculator │ days = (amountUSD / $4.99) * 30
+│ │ (stablecoins assumed 1:1 with USD)
+└──────┬──────┘
+ │
+ ▼
+┌─────────────┐
+│ localStorage│ Cache subscription status for 1 hour
+│ Cache │ Reduce API calls by 80%+
+└─────────────┘
+```
+
+## Setup Instructions
+
+### 1. Environment Variables
+
+Copy `.env.local.example` to `.env.local` and configure:
+
+```bash
+# Required: Alchemy API key (free tier: 300M compute units/month)
+ALCHEMY_API_KEY=your_alchemy_api_key
+
+# Required: Payment wallet address (same across all chains)
+# NEXT_PUBLIC_ prefix needed because it's displayed in the browser UI
+NEXT_PUBLIC_# NEXT_PUBLIC_ prefix needed because it's displayed in the browser UI
+
+# Optional: Alchemy Prices API Pro API key (free tier usually sufficient)
+# COINGECKO_API_KEY=your_alchemy-prices_pro_api_key
+
+# Optional: Cache duration in hours (default: 1)
+NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS=1
+
+# Required: Dynamic.xyz for wallet connection
+NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id
+```
+
+### 2. Supported Chains
+
+The system supports 5 EVM chains:
+- **Ethereum**
+- **Base**
+- **Arbitrum**
+- **Optimism**
+- **Polygon**
+
+**USDC**, **USDT**, and **DAI** are accepted on all chains.
+
+### 3. Pricing Model
+
+```
+Payment Amount (USD) = Subscription Days
+-------------------------------------------
+$4.99 = 30 days (1 month)
+$9.98 = 60 days (2 months)
+$2.50 = 15 days (~2 weeks)
+$10.00 = 60 days (2 months)
+$1.00 = 6 days (minimum)
+```
+
+Formula: `days = (amountUSD / 4.99) * 30`
+
+Payments are cumulative - users can top up anytime.
+
+## API Endpoints
+
+### Check Subscription Status
+
+**POST** `/api/check-subscription`
+
+Request:
+```json
+{
+ "walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
+}
+```
+
+Response:
+```json
+{
+ "success": true,
+ "subscription": {
+ "isActive": true,
+ "expiresAt": "2025-02-15T10:30:00.000Z",
+ "daysRemaining": 45,
+ "totalDaysPurchased": 60,
+ "totalPaid": 9.98,
+ "paymentCount": 2
+ }
+}
+```
+
+## Caching Strategy
+
+### localStorage Cache
+- **Key**: `subscription_{walletAddress}`
+- **Duration**: 1 hour (configurable via `NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS`)
+- **Cache hit rate**: ~80-90% (users don't refresh constantly)
+
+### In-Memory Price Cache
+- **Alchemy Prices API historical prices** are cached in memory for 24 hours
+- Prices don't change after the fact, so cache can be long-lived
+- Reduces Alchemy Prices API API calls by ~95%
+
+### Performance
+- **Cache hit**: <1ms (localStorage read)
+- **Cache miss**: 2-5 seconds (Alchemy + Alchemy Prices API API calls)
+- **Average**: <100ms (with 80% cache hit rate)
+
+## API Rate Limits & Costs
+
+### Free Tier (Sufficient for Initial Launch)
+
+| Service | Free Tier | Usage Pattern | Sufficient For |
+|---------|-----------|---------------|----------------|
+| **Alchemy** | 300M compute units/month | ~2-5 CU per subscription check | ~5,000 daily active users |
+| **Alchemy Prices API** | 10-30 calls/minute | ~0.5 calls per new payment | ~20k payments/month |
+
+### Upgrade Path
+
+**Alchemy Growth ($49/mo)**
+- 1.5B compute units/month
+- Handles ~25,000 daily active users
+
+**Alchemy Prices API Pro ($129/mo)**
+- 500 calls/minute
+- Only needed if processing >100k payments/month
+
+## Benefits Over Database Approach
+
+1. **No infrastructure costs** - No PostgreSQL hosting fees
+2. **No maintenance** - No database backups, migrations, or scaling
+3. **Zero personal data** - No GDPR/privacy concerns
+4. **Transparent** - All subscription data verifiable on-chain
+5. **Stateless** - Easy to scale horizontally
+6. **Simpler codebase** - ~1,000 lines of code removed
+7. **No price volatility** - Stablecoins maintain 1:1 USD peg
+
+## Security Considerations
+
+### What's Secure
+- ✅ Payment wallet is view-only (users can verify balance)
+- ✅ Subscription calculation is deterministic (same for all nodes)
+- ✅ No personal data stored (just wallet addresses)
+- ✅ localStorage cache can't be exploited (backend verifies on API calls)
+
+### What to Monitor
+- 🔍 Watch payment wallet balance and withdraw regularly
+- 🔍 Monitor Alchemy API usage to avoid rate limits
+- 🔍 Set up alerts for unusual payment patterns
+
+## Troubleshooting
+
+### Subscription not activating after payment
+
+1. **Check transaction confirmed**: View on block explorer (Etherscan, Basescan, etc.)
+2. **Verify payment amount**: Minimum $1 USD
+3. **Verify sender wallet**: Must match connected wallet in app
+4. **Clear localStorage cache**: Force refresh with `localStorage.removeItem('subscription_...')`
+5. **Check Alchemy API key**: Ensure valid and not rate-limited
+
+### "Failed to check subscription" error
+
+1. **Check API keys**: Alchemy API key must be valid
+2. **Check wallet address format**: Must be valid EVM address (0x...)
+3. **Check network connectivity**: API calls may be timing out
+4. **Check browser console**: Look for specific error messages
+
+### Cache not updating after payment
+
+- **Solution**: Call `refreshSubscription()` from AuthProvider
+- **Alternative**: Wait 1 hour for automatic cache expiration
+- **Manual**: Clear localStorage: `localStorage.clear()`
+
+## Testing
+
+### Testnet Testing (Recommended)
+
+1. Get testnet stablecoins from faucets (Sepolia, Base Sepolia, etc.)
+2. Configure testnet payment wallet in `.env.local`
+3. Send test transaction
+4. Verify subscription activates
+
+### Mainnet Testing (Small Amount)
+
+1. Send $1 worth of USDC, USDT, or DAI from connected wallet
+2. Wait ~2 minutes for blockchain confirmation
+3. Refresh page to check subscription status
+4. Verify 6 days added to subscription
+
+## Migration from Old System
+
+If you were using the old Paddle + Database system:
+
+1. **No data migration needed** - Old subscriptions won't transfer
+2. **Remove database**: Drop payment tables (`users`, `subscriptions`, `payments`, `webhook_events`)
+3. **Remove environment variables**: Remove all `PADDLE_*` and `POSTGRES_URL` (payment-related)
+4. **Deploy new code**: The system is now 100% blockchain-based
+
+## Future Enhancements
+
+Potential improvements:
+- [ ] Add email notifications when subscription expires
+- [ ] Add QR code for mobile wallet payments
+- [ ] Support more chains (Polygon, Avalanche, etc.)
+- [ ] Add token price charts in UI
+- [ ] Add refund mechanism (for accidental overpayments)
+
+## Support
+
+For payment issues:
+- Check blockchain explorer for transaction status
+- Verify payment wallet address is correct
+- Contact support with transaction hash
+
+For technical issues:
+- Check browser console for errors
+- Verify API keys are configured
+- Review server logs for detailed error messages
diff --git a/STRIPE_INTEGRATION.md b/STRIPE_INTEGRATION.md
new file mode 100644
index 0000000..b7158cc
--- /dev/null
+++ b/STRIPE_INTEGRATION.md
@@ -0,0 +1,335 @@
+# Stripe Integration Documentation
+
+## Overview
+
+Stripe integration uses **direct API queries** to check subscription status, similar to how blockchain payments are verified via Alchemy API. This approach eliminates the need for webhooks, database storage, and complex synchronization logic.
+
+## Architecture
+
+```
+User subscribes → Stripe manages subscription → App queries Stripe API → Verify active subscription
+```
+
+**Key Benefits:**
+- ✅ No webhooks to configure or debug
+- ✅ No database tables for payment storage
+- ✅ Always up-to-date (Stripe is source of truth)
+- ✅ Consistent with blockchain payment verification
+- ✅ Simple and reliable
+
+## Environment Variables
+
+Add these to `.env.local`:
+
+```bash
+# Stripe API Keys
+STRIPE_SECRET_KEY=sk_test_your_secret_key_here
+NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
+
+# Stripe Product Configuration
+STRIPE_PRICE_ID=price_your_recurring_price_id_here
+
+# Note: STRIPE_WEBHOOK_SECRET is NOT needed for direct query approach
+```
+
+## How It Works
+
+### 1. Subscription Creation Flow
+
+When a user subscribes:
+
+1. User logs in with wallet (Dynamic.xyz)
+2. Frontend calls `/api/stripe/create-subscription` with `walletAddress`
+3. Backend creates Stripe customer with `walletAddress` in metadata
+4. Backend creates Stripe subscription with `walletAddress` in metadata
+5. Frontend shows Stripe Payment Element for user to complete payment
+6. Stripe handles recurring billing automatically
+
+**File:** `app/api/stripe/create-subscription/route.ts`
+
+### 2. Subscription Verification Flow
+
+When checking if user has access:
+
+1. User logs in with wallet
+2. Frontend calls `/api/check-subscription` with `walletAddress`
+3. Backend queries Stripe API: Search for customers with matching `walletAddress` in metadata
+4. Backend checks if customer has active subscription
+5. Returns subscription status (active/inactive, expiration date, days remaining)
+
+**Files:**
+- `lib/subscription-manager.ts` - `checkStripeSubscription()`
+- `app/api/check-subscription/route.ts` - API endpoint
+
+### 3. Combined Subscription Check
+
+The app supports both Stripe (card) and blockchain (crypto) payments:
+
+```typescript
+// Queries both Stripe API and Alchemy API in parallel
+const subscription = await checkCombinedSubscription(walletAddress);
+
+// Returns whichever subscription expires latest
+// User has access if EITHER subscription is active
+```
+
+**File:** `lib/subscription-manager.ts` - `checkCombinedSubscription()`
+
+## API Endpoints
+
+### Create Subscription
+
+**POST** `/api/stripe/create-subscription`
+
+```bash
+curl -X POST http://localhost:3000/api/stripe/create-subscription \
+ -H "Content-Type: application/json" \
+ -d '{"walletAddress": "0x1234..."}'
+```
+
+**Response:**
+```json
+{
+ "success": true,
+ "subscriptionId": "sub_...",
+ "clientSecret": "pi_..._secret_..."
+}
+```
+
+### Check Subscription
+
+**POST** `/api/check-subscription`
+
+```bash
+curl -X POST http://localhost:3000/api/check-subscription \
+ -H "Content-Type: application/json" \
+ -d '{"walletAddress": "0x1234..."}'
+```
+
+**Response:**
+```json
+{
+ "success": true,
+ "subscription": {
+ "isActive": true,
+ "expiresAt": "2025-12-15T00:00:00.000Z",
+ "daysRemaining": 30,
+ "totalDaysPurchased": 30,
+ "totalPaid": 4.99,
+ "paymentCount": 1
+ }
+}
+```
+
+## Code Structure
+
+### Frontend Components
+
+**Payment Modal** - `app/components/PaymentModal.tsx`
+- Shows subscription options (crypto vs card)
+- Handles Stripe subscription creation
+- Displays Stripe Payment Element
+
+**Stripe Form** - `app/components/StripeSubscriptionForm.tsx`
+- Loads Stripe.js
+- Renders Payment Element
+- Handles payment confirmation
+
+**Auth Provider** - `app/components/AuthProvider.tsx`
+- Checks subscription status on login
+- Caches subscription status for 1 hour
+- Provides `hasActiveSubscription` to components
+
+### Backend Logic
+
+**Subscription Manager** - `lib/subscription-manager.ts`
+- `checkStripeSubscription()` - Queries Stripe API directly
+- `checkCombinedSubscription()` - Merges Stripe + blockchain payments
+
+**API Routes:**
+- `app/api/stripe/create-subscription/route.ts` - Creates subscription
+- `app/api/check-subscription/route.ts` - Verifies subscription status
+
+## Testing
+
+### Local Development
+
+1. Use Stripe test mode keys:
+ ```bash
+ STRIPE_SECRET_KEY=sk_test_...
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
+ ```
+
+2. Test card numbers:
+ - Success: `4242 4242 4242 4242`
+ - Decline: `4000 0000 0000 0002`
+ - Requires authentication: `4000 0025 0000 3155`
+
+3. Any future expiry date and CVC will work
+
+### Testing Subscription Flow
+
+1. Start dev server: `npm run dev`
+2. Log in with wallet
+3. Click "Subscribe to Premium"
+4. Choose "Pay with Card"
+5. Enter test card: `4242 4242 4242 4242`
+6. Complete payment
+7. Verify subscription shows as active
+
+### Testing API Directly
+
+```bash
+# Check subscription status
+curl -X POST http://localhost:3000/api/check-subscription \
+ -H "Content-Type: application/json" \
+ -d '{"walletAddress": "YOUR_WALLET_ADDRESS"}'
+```
+
+## Stripe Dashboard
+
+### Create Price
+
+1. Go to https://dashboard.stripe.com/test/products
+2. Click "Add product"
+3. Name: "Premium Subscription"
+4. Price: $4.99
+5. Billing period: Monthly
+6. Click "Save product"
+7. Copy the Price ID (starts with `price_...`)
+8. Add to `.env.local` as `STRIPE_PRICE_ID`
+
+### View Subscriptions
+
+1. Go to https://dashboard.stripe.com/test/subscriptions
+2. Click a subscription to view details
+3. Check "Metadata" tab for `walletAddress`
+
+### View Customers
+
+1. Go to https://dashboard.stripe.com/test/customers
+2. Click a customer to view details
+3. Check "Metadata" tab for `walletAddress`
+
+## Rate Limits
+
+Stripe API rate limits:
+- **Test mode:** 100 requests/second
+- **Live mode:** 100 requests/second
+
+Subscription checks are cached for 1 hour in localStorage, so rate limits are rarely reached.
+
+## Production Deployment
+
+### Pre-deployment Checklist
+
+- [ ] Switch to **live** Stripe keys (not test keys)
+- [ ] Update `STRIPE_SECRET_KEY` in production environment
+- [ ] Update `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` in production environment
+- [ ] Update `STRIPE_PRICE_ID` to use live price (not test price)
+- [ ] Test subscription creation in production
+- [ ] Test subscription verification in production
+- [ ] Verify recurring billing works correctly
+
+### Environment Variables
+
+Production `.env.local`:
+```bash
+# Use LIVE keys
+STRIPE_SECRET_KEY=sk_live_...
+NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
+STRIPE_PRICE_ID=price_... # Must be from live mode
+```
+
+## Security
+
+### Wallet Address Association
+
+- Wallet addresses are stored in Stripe customer and subscription metadata
+- Addresses are normalized to lowercase for consistency
+- No blockchain transactions required for card payments
+- User proves wallet ownership via Dynamic.xyz authentication
+
+### API Keys
+
+- `STRIPE_SECRET_KEY` must NEVER be exposed to client
+- Only used in server-side API routes
+- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` is safe to expose (by design)
+
+### Subscription Verification
+
+- Subscription status queried directly from Stripe
+- Cannot be spoofed or manipulated client-side
+- User must prove wallet ownership to access subscription benefits
+
+## Troubleshooting
+
+### "No active subscription found"
+
+**Possible causes:**
+1. Subscription not created yet
+2. Subscription cancelled or expired
+3. Payment failed
+4. Wrong wallet address
+
+**Debug:**
+```bash
+# Check Stripe for subscriptions with this wallet address
+# Dashboard → Customers → Search metadata for walletAddress
+```
+
+### "Failed to check Stripe subscription"
+
+**Possible causes:**
+1. `STRIPE_SECRET_KEY` not configured
+2. Stripe API error
+3. Network timeout
+
+**Debug:**
+Check server console logs for detailed error message
+
+### Multiple subscriptions for same wallet
+
+This is supported! The system uses the subscription with the **latest expiration date**.
+
+### Subscription not recognized immediately after payment
+
+Try:
+1. Clear localStorage cache: `localStorage.removeItem('subscription_YOUR_ADDRESS')`
+2. Refresh the page
+3. Wait a few seconds for Stripe to update
+
+## Comparison with Blockchain Payments
+
+| Feature | Stripe (Card) | Blockchain (Crypto) |
+|---------|--------------|---------------------|
+| **Verification** | Direct Stripe API | Alchemy API indexer |
+| **Storage** | Stripe manages | No storage needed |
+| **Latency** | ~200-500ms | ~500-1000ms |
+| **Rate limits** | 100 req/s | 660 compute units/s |
+| **Caching** | 1 hour | 1 hour |
+| **Recurring** | Yes (automatic) | No (manual top-ups) |
+| **Refunds** | Via Stripe Dashboard | Cannot refund blockchain tx |
+
+## Migration from Webhook Approach
+
+If you previously used webhooks + database:
+
+1. Remove old data:
+ ```sql
+ DROP TABLE IF EXISTS stripe_payments;
+ ```
+
+2. Environment variables to remove:
+ - `STRIPE_WEBHOOK_SECRET` (no longer needed)
+ - `POSTGRES_DB` (no longer required for Stripe payments)
+
+3. Existing subscriptions will continue to work automatically via direct API queries
+
+## Support
+
+For Stripe integration issues:
+1. Check Stripe Dashboard logs
+2. Check server console for error messages
+3. Verify environment variables are set correctly
+4. Review Stripe API documentation: https://stripe.com/docs/api
diff --git a/app/api/analyze-study/route.ts b/app/api/analyze-study/route.ts
index 120c1de..e8c239a 100644
--- a/app/api/analyze-study/route.ts
+++ b/app/api/analyze-study/route.ts
@@ -35,7 +35,8 @@ export async function POST(request: NextRequest) {
strongest_snp_risk_allele,
or_or_beta,
ci_text,
- study_accession
+ study_accession,
+ disease_trait
FROM gwas_catalog
WHERE ${idCondition}
AND snps IS NOT NULL AND snps != ''
@@ -49,6 +50,7 @@ export async function POST(request: NextRequest) {
or_or_beta: string | null;
ci_text: string | null;
study_accession: string | null;
+ disease_trait: string | null;
}>(query, [studyId]);
if (!study) {
@@ -59,11 +61,15 @@ export async function POST(request: NextRequest) {
}
// Determine effect type from ci_text
- // Beta coefficients have "unit" in CI (e.g., "[0.0068-0.0139] unit increase")
- // Odds ratios are just numbers (e.g., "[1.08-1.15]")
- const isBeta = study.ci_text?.toLowerCase().includes('unit') ?? false;
+ // Beta coefficients have "increase" or "decrease" in CI text
+ // e.g., "[NR] unit increase", "[0.0068-0.0139] unit increase", "[112.27-112.33] increase"
+ // Odds ratios are just numbers: e.g., "[1.08-1.15]"
+ const ciTextLower = study.ci_text?.toLowerCase() ?? '';
+ const isBeta = ciTextLower.includes('increase') || ciTextLower.includes('decrease');
const effectType = isBeta ? 'beta' : 'OR';
+ // Debug logging
+
// Return only study metadata - client will perform the analysis
return NextResponse.json({
success: true,
diff --git a/app/api/check-subscription/route.ts b/app/api/check-subscription/route.ts
new file mode 100644
index 0000000..030862c
--- /dev/null
+++ b/app/api/check-subscription/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { checkCombinedSubscription } from '@/lib/subscription-manager';
+
+export async function POST(request: NextRequest) {
+ try {
+ const { walletAddress } = await request.json();
+
+ if (!walletAddress) {
+ return NextResponse.json(
+ { error: 'Wallet address required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate wallet address format (basic check)
+ if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
+ return NextResponse.json(
+ { error: 'Invalid wallet address format' },
+ { status: 400 }
+ );
+ }
+
+ // Query both blockchain (Alchemy) and Stripe payments
+ // Combined subscription includes payments from both sources
+ console.log('[Subscription Check] Checking combined subscription (blockchain + Stripe)');
+ const subscription = await checkCombinedSubscription(walletAddress);
+
+ return NextResponse.json({
+ success: true,
+ subscription: {
+ isActive: subscription.isActive,
+ expiresAt: subscription.expiresAt?.toISOString() || null,
+ daysRemaining: subscription.daysRemaining,
+ totalDaysPurchased: subscription.totalDaysPurchased,
+ totalPaid: subscription.totalPaid,
+ paymentCount: subscription.payments.length,
+ },
+ });
+ } catch (error) {
+ console.error('Subscription check error:', error);
+ return NextResponse.json(
+ {
+ error: 'Failed to check subscription',
+ details: error instanceof Error ? error.message : 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/embeddings/route.ts b/app/api/embeddings/route.ts
new file mode 100644
index 0000000..d80079f
--- /dev/null
+++ b/app/api/embeddings/route.ts
@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from "next/server";
+import { embeddingService } from "@/lib/embedding-service";
+import { validateOrigin } from "@/lib/origin-validator";
+
+/**
+ * POST /api/embeddings
+ * Generate embeddings for text strings server-side
+ *
+ * Body: { texts: string[] }
+ * Returns: { embeddings: number[][] }
+ */
+export async function POST(request: NextRequest) {
+ // Validate origin
+ const originError = validateOrigin(request);
+ if (originError) return originError;
+
+ try {
+ const body = await request.json();
+ const { texts } = body;
+
+ if (!texts || !Array.isArray(texts)) {
+ return NextResponse.json(
+ { error: "Invalid request: texts array required" },
+ { status: 400 }
+ );
+ }
+
+ if (texts.length === 0) {
+ return NextResponse.json({ embeddings: [] });
+ }
+
+ if (texts.length > 1000) {
+ return NextResponse.json(
+ { error: "Too many texts: maximum 1000 per request" },
+ { status: 400 }
+ );
+ }
+
+ console.log(`[Embeddings API] Generating embeddings for ${texts.length} texts...`);
+ const startTime = Date.now();
+
+ // Generate embeddings in parallel
+ const embeddings = await Promise.all(
+ texts.map(async (text) => {
+ try {
+ return await embeddingService.embed(text);
+ } catch (error) {
+ console.error(`[Embeddings API] Failed to generate embedding for: "${text}"`, error);
+ return null;
+ }
+ })
+ );
+
+ const elapsed = Date.now() - startTime;
+ const successCount = embeddings.filter(e => e !== null).length;
+ console.log(`[Embeddings API] Generated ${successCount}/${texts.length} embeddings in ${elapsed}ms`);
+
+ return NextResponse.json({ embeddings });
+ } catch (error) {
+ console.error("[Embeddings API] Error:", error);
+ return NextResponse.json(
+ { error: "Failed to generate embeddings" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/fetch-embeddings/route.ts b/app/api/fetch-embeddings/route.ts
new file mode 100644
index 0000000..34259f8
--- /dev/null
+++ b/app/api/fetch-embeddings/route.ts
@@ -0,0 +1,93 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getDb } from "@/lib/db";
+import { validateOrigin } from "@/lib/origin-validator";
+
+/**
+ * POST /api/fetch-embeddings
+ * Fetch pre-computed embeddings from PostgreSQL by composite keys
+ *
+ * Body: { keys: Array<{ study_accession: string, snps: string, strongest_snp_risk_allele: string }> }
+ * Returns: { embeddings: Array<{ key: string, embedding: number[] | null }> }
+ */
+export async function POST(request: NextRequest) {
+ // Validate origin
+ const originError = validateOrigin(request);
+ if (originError) return originError;
+
+ try {
+ const body = await request.json();
+ const { keys } = body;
+
+ if (!keys || !Array.isArray(keys)) {
+ return NextResponse.json(
+ { error: "Invalid request: keys array required" },
+ { status: 400 }
+ );
+ }
+
+ if (keys.length === 0) {
+ return NextResponse.json({ embeddings: [] });
+ }
+
+ if (keys.length > 1000) {
+ return NextResponse.json(
+ { error: "Too many keys: maximum 1000 per request" },
+ { status: 400 }
+ );
+ }
+
+ console.log(`[Fetch Embeddings API] Fetching ${keys.length} embeddings from PostgreSQL...`);
+ const startTime = Date.now();
+
+ // Build WHERE clause for batch lookup
+ const conditions = keys.map((_, i) =>
+ `(study_accession = $${i * 3 + 1} AND snps = $${i * 3 + 2} AND strongest_snp_risk_allele = $${i * 3 + 3})`
+ ).join(' OR ');
+
+ const params = keys.flatMap((k: any) => [k.study_accession, k.snps, k.strongest_snp_risk_allele]);
+
+ const query = `
+ SELECT study_accession, snps, strongest_snp_risk_allele, embedding
+ FROM study_embeddings
+ WHERE ${conditions}
+ `;
+
+ const db = getDb();
+ if (db.type !== 'postgres' || !db.postgres) {
+ return NextResponse.json(
+ { error: "Embeddings are only supported with PostgreSQL" },
+ { status: 503 }
+ );
+ }
+
+ const result = await db.postgres.query(query, params);
+
+ // Create lookup map
+ const embeddingMap = new Map();
+ for (const row of result.rows) {
+ const key = `${row.study_accession}|${row.snps}|${row.strongest_snp_risk_allele}`;
+ embeddingMap.set(key, row.embedding);
+ }
+
+ // Return embeddings in same order as keys
+ const embeddings = keys.map((k: any) => {
+ const key = `${k.study_accession}|${k.snps}|${k.strongest_snp_risk_allele}`;
+ return {
+ key,
+ embedding: embeddingMap.get(key) || null
+ };
+ });
+
+ const elapsed = Date.now() - startTime;
+ const foundCount = embeddings.filter(e => e.embedding !== null).length;
+ console.log(`[Fetch Embeddings API] Found ${foundCount}/${keys.length} embeddings in ${elapsed}ms`);
+
+ return NextResponse.json({ embeddings });
+ } catch (error) {
+ console.error("[Fetch Embeddings API] Error:", error);
+ return NextResponse.json(
+ { error: "Failed to fetch embeddings" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
new file mode 100644
index 0000000..ded21b4
--- /dev/null
+++ b/app/api/health/route.ts
@@ -0,0 +1,44 @@
+/**
+ * Health check endpoint
+ *
+ * - Checks if app is running
+ * - Warms up embedding model on startup (prevents cold start delays)
+ * - Used by DO App Platform for readiness checks
+ */
+
+import { NextResponse } from 'next/server';
+import { embeddingService } from '@/lib/embedding-service';
+
+export async function GET() {
+ try {
+ // Initialize embedding model (no-op if already loaded)
+ await embeddingService.initialize();
+
+ const info = embeddingService.getInfo();
+
+ return NextResponse.json({
+ status: 'healthy',
+ timestamp: new Date().toISOString(),
+ embedding: {
+ modelLoaded: info.ready,
+ modelName: info.modelName,
+ dimensions: info.dimensions,
+ quantized: info.quantized,
+ },
+ });
+ } catch (error) {
+ console.error('[Health] Model initialization error:', error);
+
+ return NextResponse.json(
+ {
+ status: 'warming',
+ timestamp: new Date().toISOString(),
+ embedding: {
+ modelLoaded: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ },
+ },
+ { status: 503 }
+ );
+ }
+}
diff --git a/app/api/nilai-delegation/route.ts b/app/api/nilai-delegation/route.ts
index 1b70004..69a4830 100644
--- a/app/api/nilai-delegation/route.ts
+++ b/app/api/nilai-delegation/route.ts
@@ -99,7 +99,7 @@ export async function POST(request: NextRequest) {
const server = new DelegationTokenServer(apiKey, {
nilauthInstance: NilAuthInstance.PRODUCTION,
expirationTime: 600, // 10 minutes validity
- tokenMaxUses: 1 // Single use for privacy
+ tokenMaxUses: 1 // Single use for privacy (fresh token fetched per message)
});
// Generate delegation token
diff --git a/app/api/similar-studies/route.ts b/app/api/similar-studies/route.ts
new file mode 100644
index 0000000..3ab57e5
--- /dev/null
+++ b/app/api/similar-studies/route.ts
@@ -0,0 +1,88 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getDb } from "@/lib/db";
+import { validateOrigin } from "@/lib/origin-validator";
+import { embeddingService } from "@/lib/embedding-service";
+
+/**
+ * POST /api/similar-studies
+ * Find studies most similar to a query using vector similarity search
+ *
+ * Body: { query: string, limit: number }
+ * Returns: { studies: Array<{ study_accession, snps, strongest_snp_risk_allele, similarity }> }
+ */
+export async function POST(request: NextRequest) {
+ // Validate origin
+ const originError = validateOrigin(request);
+ if (originError) return originError;
+
+ try {
+ const body = await request.json();
+ const { query, limit = 500 } = body;
+
+ if (!query || typeof query !== 'string') {
+ return NextResponse.json(
+ { error: "Invalid request: query string required" },
+ { status: 400 }
+ );
+ }
+
+ if (limit > 50000) {
+ return NextResponse.json(
+ { error: "Limit cannot exceed 50000" },
+ { status: 400 }
+ );
+ }
+
+ console.log(`[Similar Studies API] Finding ${limit} studies similar to: "${query}"`);
+ const startTime = Date.now();
+
+ // Generate embedding for query
+ const queryEmbedding = await embeddingService.embed(query);
+
+ // Get database connection
+ const dbConn = getDb();
+
+ if (dbConn.type !== 'postgres' || !dbConn.postgres) {
+ return NextResponse.json(
+ { error: "Vector similarity search requires PostgreSQL" },
+ { status: 503 }
+ );
+ }
+
+ // Use PostgreSQL's vector similarity search with HNSW index
+ // Strategy: Aggregate at study level to avoid missing SNPs due to gene context in embeddings
+ // For each study, we take the MAX similarity across all its SNPs
+ const sqlQuery = `
+ WITH ranked_studies AS (
+ SELECT
+ study_accession,
+ MAX(1 - (embedding <=> $1::vector)) as max_similarity
+ FROM study_embeddings
+ GROUP BY study_accession
+ ORDER BY max_similarity DESC
+ LIMIT $2
+ )
+ SELECT DISTINCT
+ rs.study_accession,
+ rs.max_similarity as similarity
+ FROM ranked_studies rs
+ ORDER BY rs.max_similarity DESC
+ `;
+
+ const result = await dbConn.postgres.query(sqlQuery, [JSON.stringify(queryEmbedding), limit]);
+
+ const elapsed = Date.now() - startTime;
+ console.log(`[Similar Studies API] Found ${result.rows.length} similar studies in ${elapsed}ms`);
+
+ return NextResponse.json({
+ studies: result.rows,
+ query_embedding_dims: queryEmbedding.length
+ });
+ } catch (error) {
+ console.error("[Similar Studies API] Error:", error);
+ return NextResponse.json(
+ { error: "Failed to find similar studies" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/stripe/cancel-subscription/route.ts b/app/api/stripe/cancel-subscription/route.ts
new file mode 100644
index 0000000..5b0c8cb
--- /dev/null
+++ b/app/api/stripe/cancel-subscription/route.ts
@@ -0,0 +1,84 @@
+import { NextRequest, NextResponse } from 'next/server';
+import Stripe from 'stripe';
+
+const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
+ apiVersion: '2025-02-24.acacia',
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // Get wallet address from the authenticated user (would typically come from session/auth)
+ // For now, we'll require it in the request body
+ const body = await request.json().catch(() => ({}));
+ const { walletAddress } = body;
+
+ if (!walletAddress) {
+ return NextResponse.json(
+ { error: 'Wallet address required' },
+ { status: 400 }
+ );
+ }
+
+ const normalizedAddress = walletAddress.toLowerCase();
+
+ // Find all customers with this wallet address
+ const customers = await stripe.customers.search({
+ query: `metadata['walletAddress']:'${normalizedAddress}'`,
+ limit: 100,
+ });
+
+ if (customers.data.length === 0) {
+ return NextResponse.json(
+ { error: 'No subscription found for this wallet address' },
+ { status: 404 }
+ );
+ }
+
+ // Find all active subscriptions for these customers
+ const cancelledSubscriptions: string[] = [];
+
+ for (const customer of customers.data) {
+ const subscriptions = await stripe.subscriptions.list({
+ customer: customer.id,
+ status: 'active',
+ limit: 100,
+ });
+
+ // Cancel all active subscriptions (cancel at period end)
+ for (const subscription of subscriptions.data) {
+ const updated = await stripe.subscriptions.update(subscription.id, {
+ cancel_at_period_end: true,
+ });
+ cancelledSubscriptions.push(subscription.id);
+ console.log(`[Stripe] Cancelled subscription: ${subscription.id} for wallet ${walletAddress}`, {
+ cancel_at_period_end: updated.cancel_at_period_end,
+ current_period_end: new Date(updated.current_period_end * 1000).toISOString(),
+ status: updated.status,
+ });
+ }
+ }
+
+ if (cancelledSubscriptions.length === 0) {
+ return NextResponse.json(
+ { error: 'No active subscriptions found to cancel' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: `${cancelledSubscriptions.length} subscription(s) will be cancelled at the end of the billing period`,
+ cancelledSubscriptions,
+ });
+ } catch (error: any) {
+ console.error('[Stripe] Subscription cancellation error:', error);
+
+ return NextResponse.json(
+ {
+ error: 'Failed to cancel subscription',
+ details: error.message || 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/stripe/create-checkout/route.ts b/app/api/stripe/create-checkout/route.ts
new file mode 100644
index 0000000..331b9f2
--- /dev/null
+++ b/app/api/stripe/create-checkout/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from 'next/server';
+import Stripe from 'stripe';
+
+const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
+ apiVersion: '2025-02-24.acacia',
+});
+
+const DAYS_PER_MONTH = 30;
+
+export async function POST(request: NextRequest) {
+ try {
+ const { walletAddress } = await request.json();
+
+ // Validate inputs
+ if (!walletAddress) {
+ return NextResponse.json(
+ { error: 'Wallet address required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate wallet address format
+ if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
+ return NextResponse.json(
+ { error: 'Invalid wallet address format' },
+ { status: 400 }
+ );
+ }
+
+ // Validate Stripe configuration
+ if (!process.env.STRIPE_SECRET_KEY) {
+ return NextResponse.json(
+ { error: 'Stripe is not configured' },
+ { status: 500 }
+ );
+ }
+
+ if (!process.env.STRIPE_PRICE_ID) {
+ return NextResponse.json(
+ { error: 'Stripe price is not configured. Set STRIPE_PRICE_ID environment variable.' },
+ { status: 500 }
+ );
+ }
+
+ // Get the base URL for redirect
+ const origin = request.headers.get('origin') || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
+
+ // Create Stripe Checkout Session for subscription
+ const session = await stripe.checkout.sessions.create({
+ payment_method_types: ['card'],
+ mode: 'subscription', // Recurring subscription
+ line_items: [
+ {
+ price: process.env.STRIPE_PRICE_ID, // Reference existing price from Stripe Dashboard
+ quantity: 1,
+ },
+ ],
+ success_url: `${origin}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${origin}/payment/cancel`,
+ metadata: {
+ walletAddress: walletAddress.toLowerCase(),
+ },
+ subscription_data: {
+ metadata: {
+ walletAddress: walletAddress.toLowerCase(),
+ },
+ },
+ customer_email: undefined, // Optional: can be pre-filled if user provides email
+ });
+
+ console.log(`[Stripe] Created subscription checkout session: ${session.id} for wallet ${walletAddress}`);
+
+ return NextResponse.json({
+ success: true,
+ sessionId: session.id,
+ checkoutUrl: session.url,
+ });
+ } catch (error: any) {
+ console.error('Stripe checkout creation error:', error);
+
+ return NextResponse.json(
+ {
+ error: 'Failed to create checkout session',
+ details: error.message || 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/stripe/create-subscription/route.ts b/app/api/stripe/create-subscription/route.ts
new file mode 100644
index 0000000..7b5340f
--- /dev/null
+++ b/app/api/stripe/create-subscription/route.ts
@@ -0,0 +1,92 @@
+import { NextRequest, NextResponse } from 'next/server';
+import Stripe from 'stripe';
+
+const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
+ apiVersion: '2025-02-24.acacia',
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ const { walletAddress } = await request.json();
+
+ // Validate inputs
+ if (!walletAddress) {
+ return NextResponse.json(
+ { error: 'Wallet address required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate wallet address format
+ if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
+ return NextResponse.json(
+ { error: 'Invalid wallet address format' },
+ { status: 400 }
+ );
+ }
+
+ // Validate Stripe configuration
+ if (!process.env.STRIPE_SECRET_KEY) {
+ return NextResponse.json(
+ { error: 'Stripe is not configured' },
+ { status: 500 }
+ );
+ }
+
+ if (!process.env.STRIPE_PRICE_ID) {
+ return NextResponse.json(
+ { error: 'Stripe price is not configured. Set STRIPE_PRICE_ID environment variable.' },
+ { status: 500 }
+ );
+ }
+
+ // Create or retrieve customer
+ // Note: Stripe API doesn't support filtering customers by metadata in list()
+ // We need to search by email or create a new customer each time
+ // For this use case, we'll create a new customer per subscription
+ const customer = await stripe.customers.create({
+ metadata: {
+ walletAddress: walletAddress.toLowerCase(),
+ },
+ });
+
+ // Create subscription
+ const subscription = await stripe.subscriptions.create({
+ customer: customer.id,
+ items: [
+ {
+ price: process.env.STRIPE_PRICE_ID,
+ },
+ ],
+ payment_behavior: 'default_incomplete',
+ payment_settings: {
+ save_default_payment_method: 'on_subscription',
+ },
+ expand: ['latest_invoice.payment_intent'],
+ metadata: {
+ walletAddress: walletAddress.toLowerCase(),
+ },
+ });
+
+ const invoice = subscription.latest_invoice as Stripe.Invoice;
+ const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
+
+ console.log(`[Stripe] Created subscription: ${subscription.id} for wallet ${walletAddress}`);
+
+ return NextResponse.json({
+ success: true,
+ subscriptionId: subscription.id,
+ clientSecret: paymentIntent.client_secret,
+ });
+ } catch (error: any) {
+ console.error('Stripe subscription creation error:', error);
+
+ return NextResponse.json(
+ {
+ error: 'Failed to create subscription',
+ details: error.message || 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/studies/route.ts b/app/api/studies/route.ts
index 6ef9833..772ace8 100644
--- a/app/api/studies/route.ts
+++ b/app/api/studies/route.ts
@@ -11,6 +11,7 @@ import {
parseSampleSize,
QualityFlag,
} from "@/lib/parsing";
+import { embeddingService } from "@/lib/embedding-service";
type ConfidenceBand = "high" | "medium" | "low";
@@ -35,6 +36,7 @@ type RawStudy = {
risk_allele_frequency: string | null;
strongest_snp_risk_allele: string | null;
snps: string | null;
+ similarity?: number; // Semantic search similarity score (0-1)
};
type Study = RawStudy & {
@@ -235,7 +237,16 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get("search")?.trim();
const trait = searchParams.get("trait")?.trim();
- const limit = Math.max(10, Math.min(Number(searchParams.get("limit")) || 75, 200));
+ const searchMode = searchParams.get("searchMode") ?? "similarity"; // "similarity" or "exact"
+ const useSemanticSearch = searchMode === "similarity"; // Use semantic search only for similarity mode
+
+ // Special parameter for "Run All" - fetches all studies with SNPs
+ const fetchAll = searchParams.get("fetchAll") === "true";
+ // Allow larger batches for pagination (up to 100000 for Run All with fetchAll)
+ const requestedLimit = Number(searchParams.get("limit")) || 75;
+ const limit = fetchAll ? Math.max(10, Math.min(requestedLimit, 100000)) : Math.max(10, Math.min(requestedLimit, 50000));
+ const offset = Math.max(0, Number(searchParams.get("offset")) || 0);
+
const sort = searchParams.get("sort") ?? "relevance";
const direction = searchParams.get("direction") === "asc" ? "asc" : "desc";
const minSampleSize = parseInteger(searchParams.get("minSampleSize"));
@@ -251,66 +262,346 @@ export async function GET(request: NextRequest) {
const filters: string[] = [];
const params: unknown[] = [];
+ let orderByClause = "";
+ let useSemanticQuery = false;
+ let queryEmbedding: number[] = [];
+
+ // Semantic search: Generate embedding for query
+ if (search && useSemanticSearch) {
+ try {
+ console.log(`[Semantic Search] Embedding query: "${search}"`);
+ queryEmbedding = await embeddingService.embed(search);
+ useSemanticQuery = true;
+ console.log(`[Semantic Search] Embedding generated (${queryEmbedding.length} dims)`);
+ console.log(`[Semantic Search] First 10 values:`, queryEmbedding.slice(0, 10));
+ } catch (error) {
+ console.error(`[Semantic Search] Failed to generate embedding:`, error);
+ // Fall back to keyword search
+ useSemanticQuery = false;
+ }
+ }
- if (search) {
+ // Build search filters
+ if (search && !useSemanticQuery) {
+ // Keyword search (fallback or when semantic is disabled)
const wildcard = `%${search}%`;
filters.push(
- "(study LIKE ? OR disease_trait LIKE ? OR mapped_trait LIKE ? OR first_author LIKE ? OR mapped_gene LIKE ? OR study_accession LIKE ?)",
+ "(gc.study LIKE ? OR gc.disease_trait LIKE ? OR gc.mapped_trait LIKE ? OR gc.first_author LIKE ? OR gc.mapped_gene LIKE ? OR gc.study_accession LIKE ? OR gc.snps LIKE ?)",
);
- params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
+ params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
+ } else if (useSemanticQuery) {
+ // Semantic search: Use separate study_embeddings table
+ const dbType = getDbType();
+
+ if (dbType === 'postgres') {
+ // PostgreSQL: Semantic search handled in FROM clause subquery
+ // The subquery already filters and orders by similarity
+ // Just order by the distance column from the subquery
+ orderByClause = `ORDER BY se.distance`;
+ } else {
+ // SQLite: No native vector support, fall back to keyword search
+ console.warn(`[Semantic Search] SQLite doesn't support vector similarity, falling back to keyword search`);
+ const wildcard = `%${search}%`;
+ filters.push(
+ "(gc.study LIKE ? OR gc.disease_trait LIKE ? OR gc.mapped_trait LIKE ? OR gc.first_author LIKE ? OR gc.mapped_gene LIKE ? OR gc.study_accession LIKE ? OR gc.snps LIKE ?)",
+ );
+ params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
+ useSemanticQuery = false;
+ }
}
if (trait) {
- filters.push("(mapped_trait = ? OR disease_trait = ?)");
+ filters.push("(gc.mapped_trait = ? OR gc.disease_trait = ?)");
params.push(trait, trait);
}
- const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
+ // For fetchAll, always require SNPs and risk alleles (since we're doing SNP matching)
+ if (fetchAll) {
+ filters.push("(gc.snps IS NOT NULL AND gc.snps != '')");
+ filters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '')");
+ }
+
+ // Add backend filters to SQL WHERE clause
+ const maxPValue = maxPValueRaw ? parsePValue(maxPValueRaw) : null;
+ const minLogP = minLogPRaw ? Number(minLogPRaw) : null;
- // Use appropriate ID selection based on database type
+ // Get database type first to use appropriate syntax
const dbType = getDbType();
+
+ if (minSampleSize !== null) {
+ // Filter for minimum sample size
+ // Check both initial_sample_size and replication_sample_size
+ // Note: Some values contain text like "1,000 cases, 1,034 controls"
+ // Extract only the first number to avoid overflow from concatenating multiple numbers
+ if (dbType === 'postgres') {
+ // Extract first number with commas, then remove commas
+ filters.push("((NULLIF(regexp_replace((regexp_match(gc.initial_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric) OR (NULLIF(regexp_replace((regexp_match(gc.replication_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric))");
+ } else {
+ filters.push("((CAST(gc.initial_sample_size AS INTEGER) >= ?) OR (CAST(gc.replication_sample_size AS INTEGER) >= ?))");
+ }
+ params.push(minSampleSize, minSampleSize);
+ }
+
+ if (maxPValue !== null) {
+ // Filter for maximum p-value
+ // Use pvalue_mlog to avoid numeric overflow with extreme p-values like 1E-18716
+ // Convert: maxPValue = 5e-8 => minLogP = -log10(5e-8) ≈ 7.3
+ const minLogPFromMaxP = -Math.log10(maxPValue);
+ if (dbType === 'postgres') {
+ filters.push("(gc.pvalue_mlog IS NULL OR gc.pvalue_mlog::numeric >= ?::numeric)");
+ } else {
+ filters.push("(gc.pvalue_mlog IS NULL OR CAST(gc.pvalue_mlog AS REAL) >= ?)");
+ }
+ params.push(minLogPFromMaxP);
+ }
+
+ if (minLogP !== null) {
+ // Filter for minimum -log10(p-value)
+ if (dbType === 'postgres') {
+ filters.push("gc.pvalue_mlog::numeric >= ?::numeric");
+ } else {
+ filters.push("CAST(gc.pvalue_mlog AS REAL) >= ?");
+ }
+ params.push(minLogP);
+ }
+
+ if (excludeMissingGenotype) {
+ // Filter out studies with missing or invalid genotype data
+ filters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '' AND gc.strongest_snp_risk_allele != '?' AND gc.strongest_snp_risk_allele != 'NR' AND gc.strongest_snp_risk_allele NOT LIKE '%?%')");
+ }
+
+ const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
// NOTE: hashtext() is a 32-bit non-cryptographic hash with potential collision risk.
// For production with high study volumes, consider migrating to a stable UUID column
// computed during data ingestion to eliminate collision probability.
// Current risk is low given GWAS catalog size (~hundreds of thousands of studies) and
// the composite key includes multiple discriminating fields (accession, SNPs, p-value, OR).
const idSelection = dbType === 'postgres'
- ? 'hashtext(COALESCE(study_accession, \'\') || COALESCE(snps, \'\') || COALESCE(strongest_snp_risk_allele, \'\') || COALESCE(p_value, \'\') || COALESCE(or_or_beta::text, \'\')) AS id'
- : 'rowid AS id';
+ ? 'hashtext(COALESCE(gc.study_accession, \'\') || COALESCE(gc.snps, \'\') || COALESCE(gc.strongest_snp_risk_allele, \'\') || COALESCE(gc.p_value, \'\') || COALESCE(gc.or_or_beta::text, \'\')) AS id'
+ : 'gc.rowid AS id';
+
+ // Calculate rawLimit first (needed for HNSW candidate limit calculation)
+ // Most filters now run in SQL, so we only need a small buffer for excludeLowQuality and confidenceBand
+ // These filters are applied post-query in JavaScript
+ const needsPostFilterBuffer = excludeLowQuality || confidenceBandFilter !== null;
+ const isRunAllQuery = excludeLowQuality === false && excludeMissingGenotype === false && !search && !trait;
+ // Use 2x buffer for post-filtering to ensure enough results after JS-side filtering
+ // Respect user's limit choice (removed arbitrary 200 cap)
+ const rawLimit = fetchAll ? limit : (needsPostFilterBuffer ? limit * 2 : limit);
+
+ // Build FROM clause - for semantic search, query embeddings table first, then join
+ // This allows the HNSW index to be used efficiently
+ let fromClause = 'FROM gwas_catalog gc';
+
+ if (useSemanticQuery && dbType === 'postgres') {
+ // Two-stage query for efficient HNSW index usage:
+ // 1. First: Use HNSW index to get top candidate embeddings (fast!)
+ // 2. Then: JOIN with gwas_catalog using composite key (study_accession, snps, strongest_snp_risk_allele)
+ // This prevents full table scans by letting the HNSW index do the heavy lifting
+
+ const vectorLiteral = `'${JSON.stringify(queryEmbedding)}'::vector`;
+ // Dynamic candidate limit based on user's requested limit and filter strictness
+ // Use larger multiplier when filters are active to ensure enough results after filtering
+ const hasStrictFilters = minSampleSize !== null || maxPValue !== null || minLogP !== null || excludeMissingGenotype;
+ const candidateMultiplier = hasStrictFilters ? 10 : 5; // 10x with filters, 5x without
+ const hnswCandidateLimit = Math.max(1000, Math.min(rawLimit * candidateMultiplier, 10000));
+
+ fromClause = `FROM (
+ SELECT study_accession, snps, strongest_snp_risk_allele, embedding
+ FROM study_embeddings
+ ORDER BY embedding <=> ${vectorLiteral}
+ LIMIT ${hnswCandidateLimit}
+ ) se
+ INNER JOIN gwas_catalog gc ON (
+ se.study_accession = gc.study_accession
+ AND se.snps = gc.snps
+ AND se.strongest_snp_risk_allele = gc.strongest_snp_risk_allele
+ )`;
+
+ // Update order by to use embedding distance (ASC = lowest distance first = highest similarity first = descending similarity)
+ orderByClause = `ORDER BY se.embedding <=> ${vectorLiteral}`;
+ }
+
+ // Add similarity score for semantic search
+ const similarityColumn = useSemanticQuery && dbType === 'postgres'
+ ? `,\n (1 - (se.embedding <=> ${`'${JSON.stringify(queryEmbedding)}'::vector`})) as similarity`
+ : '';
const baseQuery = `SELECT ${idSelection},
- study_accession,
- study,
- disease_trait,
- mapped_trait,
- mapped_trait_uri,
- mapped_gene,
- first_author,
- date,
- journal,
- pubmedid,
- link,
- initial_sample_size,
- replication_sample_size,
- p_value,
- pvalue_mlog,
- or_or_beta,
- risk_allele_frequency,
- strongest_snp_risk_allele,
- snps
- FROM gwas_catalog
+ gc.study_accession,
+ gc.study,
+ gc.disease_trait,
+ gc.mapped_trait,
+ gc.mapped_trait_uri,
+ gc.mapped_gene,
+ gc.first_author,
+ gc.date,
+ gc.journal,
+ gc.pubmedid,
+ gc.link,
+ gc.initial_sample_size,
+ gc.replication_sample_size,
+ gc.p_value,
+ gc.pvalue_mlog,
+ gc.or_or_beta,
+ gc.risk_allele_frequency,
+ gc.strongest_snp_risk_allele,
+ gc.snps${similarityColumn}
+ ${fromClause}
${whereClause}
- LIMIT ?`;
- const rawLimit = Math.min(limit * 4, 800);
+ ${orderByClause}`;
+
+ // Add LIMIT/OFFSET directly to query for PostgreSQL (safe since rawLimit and offset are validated integers)
+ // This avoids parameter type inference issues with pg driver
+ const finalQuery = dbType === 'postgres'
+ ? `${baseQuery}\n LIMIT ${rawLimit} OFFSET ${offset}`
+ : `${baseQuery}\n LIMIT ? OFFSET ?`;
+
+ let rawRows: RawStudy[];
try {
- const rawRows = await executeQuery(baseQuery, [...params, rawLimit]);
+ // Log query timing for semantic search debugging
+ const queryStart = Date.now();
+ console.log(`[Query] Starting database query...`);
+ if (useSemanticQuery) {
+ console.log(`[Query] Semantic search active, params count: ${params.length}`);
+ console.log(`[Query] HNSW ef_search is set to 1000 at connection level for better recall`);
+ }
- const maxPValue = maxPValueRaw ? parsePValue(maxPValueRaw) : null;
- const minLogP = minLogPRaw ? Number(minLogPRaw) : null;
+ // DEBUG: Print the actual SQL query
+ console.log(`[Query] SQL:\n${finalQuery}`);
+ console.log(`[Query] First 3 params:`, params.slice(0, 3).map(p => typeof p === 'string' && p.length > 100 ? `${p.substring(0, 100)}...` : p));
+
+ // For PostgreSQL, LIMIT/OFFSET are in the query string; for SQLite, they're in params
+ rawRows = dbType === 'postgres'
+ ? await executeQuery(finalQuery, params)
+ : await executeQuery(finalQuery, [...params, rawLimit, offset]);
+
+ const queryElapsed = Date.now() - queryStart;
+ console.log(`[Query] Database query completed in ${queryElapsed}ms`);
+ console.log(`[Query] Raw rows returned: ${rawRows.length}`);
+ if (queryElapsed > 5000) {
+ console.warn(`[Query] SLOW QUERY DETECTED: ${queryElapsed}ms - consider database upgrade`);
+ }
+ } catch (error: any) {
+ // If semantic search fails (e.g., study_embeddings table doesn't exist), fall back to keyword search
+ if (useSemanticQuery && (error?.message?.includes('study_embeddings') || error?.message?.includes('relation') || error?.message?.includes('does not exist'))) {
+ console.warn(`[Semantic Search] Table not found or query failed, falling back to keyword search:`, error.message);
+
+ // Rebuild query without semantic search, using same filters as main query
+ const fallbackFilters: string[] = [];
+ const fallbackParams: any[] = [];
+
+ // Re-add search as keyword search
+ if (search) {
+ const wildcard = `%${search}%`;
+ fallbackFilters.push(
+ "(gc.study LIKE ? OR gc.disease_trait LIKE ? OR gc.mapped_trait LIKE ? OR gc.first_author LIKE ? OR gc.mapped_gene LIKE ? OR gc.study_accession LIKE ? OR gc.snps LIKE ?)",
+ );
+ fallbackParams.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
+ }
+
+ if (trait) {
+ fallbackFilters.push("(gc.mapped_trait = ? OR gc.disease_trait = ?)");
+ fallbackParams.push(trait, trait);
+ }
+
+ if (fetchAll) {
+ fallbackFilters.push("(gc.snps IS NOT NULL AND gc.snps != '')");
+ fallbackFilters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '')");
+ }
+
+ // Add the same backend filters as the main query
+ if (minSampleSize !== null) {
+ if (dbType === 'postgres') {
+ // Extract first number with commas, then remove commas
+ fallbackFilters.push("((NULLIF(regexp_replace((regexp_match(gc.initial_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric) OR (NULLIF(regexp_replace((regexp_match(gc.replication_sample_size, '[0-9,]+'))[1], ',', '', 'g'), '')::numeric >= ?::numeric))");
+ } else {
+ fallbackFilters.push("((CAST(gc.initial_sample_size AS INTEGER) >= ?) OR (CAST(gc.replication_sample_size AS INTEGER) >= ?))");
+ }
+ fallbackParams.push(minSampleSize, minSampleSize);
+ }
+
+ if (maxPValue !== null) {
+ // Use pvalue_mlog to avoid numeric overflow with extreme p-values
+ const minLogPFromMaxP = -Math.log10(maxPValue);
+ if (dbType === 'postgres') {
+ fallbackFilters.push("(gc.pvalue_mlog IS NULL OR gc.pvalue_mlog::numeric >= ?::numeric)");
+ } else {
+ fallbackFilters.push("(gc.pvalue_mlog IS NULL OR CAST(gc.pvalue_mlog AS REAL) >= ?)");
+ }
+ fallbackParams.push(minLogPFromMaxP);
+ }
+
+ if (minLogP !== null) {
+ if (dbType === 'postgres') {
+ fallbackFilters.push("gc.pvalue_mlog::numeric >= ?::numeric");
+ } else {
+ fallbackFilters.push("CAST(gc.pvalue_mlog AS REAL) >= ?");
+ }
+ fallbackParams.push(minLogP);
+ }
+
+ if (excludeMissingGenotype) {
+ fallbackFilters.push("(gc.strongest_snp_risk_allele IS NOT NULL AND gc.strongest_snp_risk_allele != '' AND gc.strongest_snp_risk_allele != '?' AND gc.strongest_snp_risk_allele != 'NR' AND gc.strongest_snp_risk_allele NOT LIKE '%?%')");
+ }
+
+ const fallbackWhereClause = fallbackFilters.length ? `WHERE ${fallbackFilters.join(" AND ")}` : "";
+
+ // Build order by based on sort parameter
+ let fallbackOrderBy = "";
+ if (sort === "power") {
+ fallbackOrderBy = `ORDER BY CAST(gc.initial_sample_size AS INTEGER) ${direction}`;
+ } else if (sort === "recent") {
+ fallbackOrderBy = `ORDER BY gc.date ${direction}`;
+ } else if (sort === "alphabetical") {
+ fallbackOrderBy = `ORDER BY gc.study ${direction}`;
+ } else {
+ // Default: relevance (sort by -log10(p))
+ fallbackOrderBy = `ORDER BY CAST(gc.pvalue_mlog AS REAL) ${direction}`;
+ }
+
+ const fallbackQuery = `SELECT ${idSelection},
+ gc.study_accession,
+ gc.study,
+ gc.disease_trait,
+ gc.mapped_trait,
+ gc.mapped_trait_uri,
+ gc.mapped_gene,
+ gc.first_author,
+ gc.date,
+ gc.journal,
+ gc.pubmedid,
+ gc.link,
+ gc.initial_sample_size,
+ gc.replication_sample_size,
+ gc.p_value,
+ gc.pvalue_mlog,
+ gc.or_or_beta,
+ gc.risk_allele_frequency,
+ gc.strongest_snp_risk_allele,
+ gc.snps
+ FROM gwas_catalog gc
+ ${fallbackWhereClause}
+ ${fallbackOrderBy}`;
+
+ const fallbackFinalQuery = dbType === 'postgres'
+ ? `${fallbackQuery}\n LIMIT ${rawLimit} OFFSET ${offset}`
+ : `${fallbackQuery}\n LIMIT ? OFFSET ?`;
+
+ rawRows = dbType === 'postgres'
+ ? await executeQuery(fallbackFinalQuery, fallbackParams)
+ : await executeQuery(fallbackFinalQuery, [...fallbackParams, rawLimit, offset]);
+ } else {
+ // Re-throw if it's a different error
+ throw error;
+ }
+ }
+
+ try {
const studies: Study[] = rawRows
- .map((row) => {
+ .map((row, index) => {
const sampleSize = parseSampleSize(row.initial_sample_size) ?? parseSampleSize(row.replication_sample_size);
const pValueNumeric = parsePValue(row.p_value);
const logPValue = parseLogPValue(row.pvalue_mlog) ?? (pValueNumeric ? -Math.log10(pValueNumeric) : null);
@@ -333,80 +624,90 @@ export async function GET(request: NextRequest) {
} satisfies Study;
})
.filter((row) => {
- if (minSampleSize && row.sampleSize !== null && row.sampleSize < minSampleSize) {
- return false;
- }
- if (minSampleSize && row.sampleSize === null) {
- return false;
- }
- if (maxPValue !== null && row.pValueNumeric !== null && row.pValueNumeric > maxPValue) {
- return false;
- }
- if (maxPValue !== null && row.pValueNumeric === null) {
- return false;
- }
- if (minLogP !== null && row.logPValue !== null && row.logPValue < minLogP) {
- return false;
- }
- if (minLogP !== null && row.logPValue === null) {
- return false;
- }
+ // Note: minSampleSize, maxPValue, minLogP, and excludeMissingGenotype are now handled in SQL
+ // Only keep filters that require computed fields (excludeLowQuality, confidenceBand)
if (excludeLowQuality && row.isLowQuality) {
return false;
}
- if (excludeMissingGenotype) {
- if (!row.strongest_snp_risk_allele ||
- row.strongest_snp_risk_allele.trim().length === 0 ||
- row.strongest_snp_risk_allele.trim() === '?' ||
- row.strongest_snp_risk_allele.trim() === 'NR' ||
- row.strongest_snp_risk_allele.includes('?')) {
- return false;
- }
- }
if (confidenceBandFilter && row.confidenceBand !== confidenceBandFilter) {
return false;
}
return true;
});
- const countResult = await executeQuerySingle<{ count: number }>(`SELECT COUNT(*) as count FROM gwas_catalog ${whereClause}`, params);
- const sourceCount = countResult?.count ?? 0;
+ // Get source count (may fail if numeric overflow in WHERE clause, that's ok)
+ let sourceCount = 0;
+ try {
+ const countQuery = useSemanticQuery && dbType === 'postgres'
+ ? `SELECT COUNT(*) as count ${fromClause} ${whereClause}`
+ : `SELECT COUNT(*) as count FROM gwas_catalog gc ${whereClause}`;
+ const countResult = await executeQuerySingle<{ count: number }>(countQuery, params);
+ sourceCount = countResult?.count ?? 0;
+ } catch (error: any) {
+ console.warn('[Query] Count query failed:', error?.message || 'Unknown error');
+ console.warn('[Query] Using result length as count instead');
+ sourceCount = rawRows.length;
+ }
const sortedStudies = [...studies];
const directionFactor = direction === "asc" ? 1 : -1;
- switch (sort) {
- case "power":
- sortedStudies.sort((a, b) => directionFactor * ((a.sampleSize ?? 0) - (b.sampleSize ?? 0)));
- break;
- case "recent":
- sortedStudies.sort((a, b) => {
- const aDate = a.publicationDate;
- const bDate = b.publicationDate;
- if (aDate === null && bDate === null) {
- return 0;
- }
- if (aDate === null) {
- return 1;
- }
- if (bDate === null) {
- return -1;
- }
- return directionFactor * (aDate - bDate);
- });
- break;
- case "alphabetical":
- sortedStudies.sort(
- (a, b) => (a.study ?? "").localeCompare(b.study ?? "") * directionFactor,
- );
- break;
- default:
- sortedStudies.sort((a, b) => directionFactor * ((a.logPValue ?? -Infinity) - (b.logPValue ?? -Infinity)));
- break;
+ // Skip client-side sorting if semantic search already ordered by relevance
+ if (!useSemanticQuery) {
+ switch (sort) {
+ case "power":
+ sortedStudies.sort((a, b) => directionFactor * ((a.sampleSize ?? 0) - (b.sampleSize ?? 0)));
+ break;
+ case "recent":
+ sortedStudies.sort((a, b) => {
+ const aDate = a.publicationDate;
+ const bDate = b.publicationDate;
+ if (aDate === null && bDate === null) {
+ return 0;
+ }
+ if (aDate === null) {
+ return 1;
+ }
+ if (bDate === null) {
+ return -1;
+ }
+ return directionFactor * (aDate - bDate);
+ });
+ break;
+ case "alphabetical":
+ sortedStudies.sort(
+ (a, b) => (a.study ?? "").localeCompare(b.study ?? "") * directionFactor,
+ );
+ break;
+ default:
+ sortedStudies.sort((a, b) => directionFactor * ((a.logPValue ?? -Infinity) - (b.logPValue ?? -Infinity)));
+ break;
+ }
}
const finalResults = sortedStudies.slice(0, limit);
+ // For Run All queries, return minimal payload to avoid JSON serialization limits
+ if (isRunAllQuery) {
+ const minimalResults = finalResults.map(s => ({
+ id: s.id,
+ study_accession: s.study_accession,
+ disease_trait: s.disease_trait,
+ study: s.study,
+ snps: s.snps,
+ strongest_snp_risk_allele: s.strongest_snp_risk_allele,
+ or_or_beta: s.or_or_beta,
+ }));
+
+ return NextResponse.json({
+ data: minimalResults,
+ total: studies.length,
+ limit,
+ truncated: studies.length > finalResults.length,
+ sourceCount,
+ });
+ }
+
return NextResponse.json({
data: finalResults,
total: studies.length,
diff --git a/app/components/AuthProvider.tsx b/app/components/AuthProvider.tsx
new file mode 100644
index 0000000..48c0f28
--- /dev/null
+++ b/app/components/AuthProvider.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { DynamicContextProvider, DynamicWidget, useDynamicContext } from '@dynamic-labs/sdk-react-core';
+import { EthereumWalletConnectors } from '@dynamic-labs/ethereum';
+import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
+
+interface SubscriptionData {
+ isActive: boolean;
+ expiresAt: string | null;
+ daysRemaining: number;
+ totalDaysPurchased: number;
+ totalPaid: number;
+ paymentCount: number;
+}
+
+interface AuthContextType {
+ isAuthenticated: boolean;
+ user: any | null;
+ hasActiveSubscription: boolean;
+ checkingSubscription: boolean;
+ subscriptionData: SubscriptionData | null;
+ refreshSubscription: () => Promise;
+}
+
+const AuthContext = createContext({
+ isAuthenticated: false,
+ user: null,
+ hasActiveSubscription: false,
+ checkingSubscription: true,
+ subscriptionData: null,
+ refreshSubscription: async () => {},
+});
+
+export const useAuth = () => useContext(AuthContext);
+
+// Inner component to sync Dynamic context with Auth context
+function AuthStateSync({
+ onAuthStateChange,
+ onCheckSubscription,
+}: {
+ onAuthStateChange: (isAuth: boolean, user: any) => void;
+ onCheckSubscription: (walletAddress: string) => Promise;
+}) {
+ const { user: dynamicUser } = useDynamicContext();
+
+ useEffect(() => {
+ console.log('[AuthStateSync] Dynamic state:', {
+ hasUser: !!dynamicUser,
+ userAddress: dynamicUser?.verifiedCredentials?.[0]?.address
+ });
+
+ // If we have a user with a wallet, treat them as authenticated
+ const isAuth = !!dynamicUser;
+
+ // Sync Dynamic's auth state with our context
+ onAuthStateChange(isAuth, dynamicUser);
+
+ // If we have a user with wallet address, check subscription
+ if (dynamicUser) {
+ const walletAddress = dynamicUser?.verifiedCredentials?.[0]?.address;
+ if (walletAddress) {
+ console.log('[AuthStateSync] Checking subscription for wallet:', walletAddress);
+ onCheckSubscription(walletAddress);
+ } else {
+ console.warn('[AuthStateSync] User exists but no wallet address found');
+ }
+ }
+ }, [dynamicUser, onAuthStateChange, onCheckSubscription]);
+
+ return null;
+}
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [user, setUser] = useState(null);
+ const [hasActiveSubscription, setHasActiveSubscription] = useState(false);
+ const [subscriptionData, setSubscriptionData] = useState(null);
+ const [checkingSubscription, setCheckingSubscription] = useState(true);
+
+ // If environment ID is not set, render without Dynamic (useful for CI/CD builds)
+ const environmentId = process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID;
+ const isDynamicEnabled = !!environmentId;
+
+ const checkSubscription = useCallback(async (walletAddress: string) => {
+ try {
+ console.log('[AuthProvider] Checking subscription for:', walletAddress);
+ setCheckingSubscription(true);
+
+ // Query API directly - no caching
+ const response = await fetch('/api/check-subscription', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ walletAddress }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'Unknown error');
+ }
+
+ const subData = result.subscription;
+ setHasActiveSubscription(subData.isActive);
+ setSubscriptionData(subData);
+
+ console.log('[AuthProvider] Subscription check complete:', {
+ isActive: subData.isActive,
+ daysRemaining: subData.daysRemaining,
+ });
+
+ } catch (error) {
+ console.error('[AuthProvider] Failed to check subscription:', error);
+ setHasActiveSubscription(false);
+ setSubscriptionData(null);
+ } finally {
+ setCheckingSubscription(false);
+ }
+ }, []);
+
+ const refreshSubscription = async () => {
+ const walletAddress = user?.verifiedCredentials?.[0]?.address;
+ if (walletAddress) {
+ await checkSubscription(walletAddress);
+ }
+ };
+
+ // On mount, if no user is authenticated, stop checking subscription
+ useEffect(() => {
+ if (!isAuthenticated && !user) {
+ setCheckingSubscription(false);
+ }
+ }, [isAuthenticated, user]);
+
+ const handleAuthStateChange = useCallback((isAuth: boolean, dynamicUser: any) => {
+ console.log('[AuthProvider] Auth state changed:', { isAuth, hasUser: !!dynamicUser, userAddress: dynamicUser?.verifiedCredentials?.[0]?.address });
+ setIsAuthenticated(isAuth);
+ setUser(dynamicUser);
+
+ if (!isAuth) {
+ // User logged out
+ setHasActiveSubscription(false);
+ setSubscriptionData(null);
+ setCheckingSubscription(false);
+ }
+ }, []);
+
+ // If Dynamic is not enabled (no environment ID), render children with default auth context
+ if (!isDynamicEnabled) {
+ return (
+ {},
+ }}
+ >
+ {children}
+
+ );
+ }
+
+ return (
+ {
+ setIsAuthenticated(false);
+ setUser(null);
+ setHasActiveSubscription(false);
+ setSubscriptionData(null);
+ setCheckingSubscription(false);
+ },
+ },
+ }}
+ >
+
+
+ {children}
+
+
+ );
+}
+
+export function AuthButton() {
+ const environmentId = process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID;
+
+ // Don't render widget if Dynamic is not enabled
+ if (!environmentId) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/app/components/CustomizationContext.tsx b/app/components/CustomizationContext.tsx
new file mode 100644
index 0000000..f5d6c01
--- /dev/null
+++ b/app/components/CustomizationContext.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect, ReactNode } from "react";
+import { encryptData, decryptData, EncryptedData } from "@/lib/encryption-utils";
+import { isDevModeEnabled, getPersonalizationPassword, savePersonalizationPassword } from "@/lib/dev-mode";
+
+export interface UserCustomization {
+ ethnicities: string[];
+ countriesOfOrigin: string[];
+ genderAtBirth: string;
+ age: number | null;
+ personalConditions: string[];
+ familyConditions: string[];
+ smokingHistory?: 'still-smoking' | 'past-smoker' | 'never-smoked' | '';
+ alcoholUse?: 'none' | 'rare' | 'mild' | 'moderate' | 'heavy' | '';
+ medications?: string[];
+ diet?: 'regular' | 'vegetarian' | 'vegan' | 'pescatarian' | 'keto' | 'paleo' | 'carnivore' | 'mediterranean' | 'low-carb' | 'gluten-free' | '';
+}
+
+type CustomizationStatus = 'not-set' | 'locked' | 'unlocked';
+
+type CustomizationContextType = {
+ customization: UserCustomization | null;
+ status: CustomizationStatus;
+ saveCustomization: (data: UserCustomization, password: string) => Promise;
+ unlockCustomization: (password: string) => Promise;
+ lockCustomization: () => void;
+ clearCustomization: () => void;
+};
+
+const CustomizationContext = createContext(null);
+
+const STORAGE_KEY = 'user_customization_encrypted';
+
+const defaultCustomization: UserCustomization = {
+ ethnicities: [],
+ countriesOfOrigin: [],
+ genderAtBirth: '',
+ age: null,
+ personalConditions: [],
+ familyConditions: [],
+ smokingHistory: '',
+ alcoholUse: '',
+ medications: [],
+ diet: '',
+};
+
+export function CustomizationProvider({ children }: { children: ReactNode }) {
+ const [customization, setCustomization] = useState(null);
+ const [status, setStatus] = useState('not-set');
+ const [devModeAutoUnlockAttempted, setDevModeAutoUnlockAttempted] = useState(false);
+
+ useEffect(() => {
+ // Check if encrypted data exists in localStorage
+ if (typeof window !== 'undefined') {
+ const encrypted = localStorage.getItem(STORAGE_KEY);
+ if (encrypted) {
+ setStatus('locked');
+ } else {
+ setStatus('not-set');
+ }
+ }
+ }, []);
+
+ // Dev mode: Auto-unlock personalization on mount
+ useEffect(() => {
+ if (devModeAutoUnlockAttempted || status !== 'locked') return;
+
+ const autoUnlock = async () => {
+ if (!isDevModeEnabled()) {
+ setDevModeAutoUnlockAttempted(true);
+ return;
+ }
+
+ console.log('[Dev Mode] 🚀 Attempting to auto-unlock personalization...');
+
+ try {
+ const savedPassword = await getPersonalizationPassword();
+ if (savedPassword) {
+ const success = await unlockCustomization(savedPassword);
+ if (success) {
+ console.log('[Dev Mode] ✓ Personalization auto-unlocked successfully');
+ } else {
+ console.log('[Dev Mode] Failed to unlock - password may have changed');
+ }
+ } else {
+ console.log('[Dev Mode] No saved password found. Unlock once to enable auto-unlock.');
+ }
+ } catch (error) {
+ console.error('[Dev Mode] Failed to auto-unlock personalization:', error);
+ } finally {
+ setDevModeAutoUnlockAttempted(true);
+ }
+ };
+
+ autoUnlock();
+ }, [status, devModeAutoUnlockAttempted]);
+
+ const saveCustomization = async (data: UserCustomization, password: string) => {
+ if (!password || password.length < 6) {
+ throw new Error('Password must be at least 6 characters');
+ }
+
+ const encrypted = await encryptData(JSON.stringify(data), password);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted));
+
+ setCustomization(data);
+ setStatus('unlocked');
+
+ // Dev mode: Save password for auto-unlock
+ if (isDevModeEnabled()) {
+ await savePersonalizationPassword(password);
+ }
+ };
+
+ const unlockCustomization = async (password: string): Promise => {
+ const encryptedStr = localStorage.getItem(STORAGE_KEY);
+ if (!encryptedStr) {
+ return false;
+ }
+
+ try {
+ const encrypted: EncryptedData = JSON.parse(encryptedStr);
+ const decrypted = await decryptData(encrypted, password);
+ const data: UserCustomization = JSON.parse(decrypted);
+
+ setCustomization(data);
+ setStatus('unlocked');
+
+ // Dev mode: Save password for auto-unlock
+ if (isDevModeEnabled()) {
+ await savePersonalizationPassword(password);
+ }
+
+ return true;
+ } catch (error) {
+ return false;
+ }
+ };
+
+ const lockCustomization = () => {
+ setCustomization(null);
+ setStatus('locked');
+ };
+
+ const clearCustomization = () => {
+ localStorage.removeItem(STORAGE_KEY);
+ setCustomization(null);
+ setStatus('not-set');
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCustomization() {
+ const context = useContext(CustomizationContext);
+ if (!context) {
+ throw new Error('useCustomization must be used within CustomizationProvider');
+ }
+ return context;
+}
diff --git a/app/components/CustomizationModal.tsx b/app/components/CustomizationModal.tsx
new file mode 100644
index 0000000..a219824
--- /dev/null
+++ b/app/components/CustomizationModal.tsx
@@ -0,0 +1,456 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import { useCustomization, UserCustomization } from "./CustomizationContext";
+
+type CustomizationModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export default function CustomizationModal({ isOpen, onClose }: CustomizationModalProps) {
+ const { customization, status, saveCustomization, unlockCustomization, lockCustomization, clearCustomization } = useCustomization();
+
+ const [isUnlockMode, setIsUnlockMode] = useState(false);
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [error, setError] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Form fields
+ const [ethnicities, setEthnicities] = useState('');
+ const [countriesOfOrigin, setCountriesOfOrigin] = useState('');
+ const [genderAtBirth, setGenderAtBirth] = useState('');
+ const [customGender, setCustomGender] = useState('');
+ const [age, setAge] = useState('');
+ const [personalConditions, setPersonalConditions] = useState('');
+ const [familyConditions, setFamilyConditions] = useState('');
+ const [smokingHistory, setSmokingHistory] = useState('');
+ const [alcoholUse, setAlcoholUse] = useState('');
+ const [medications, setMedications] = useState('');
+ const [diet, setDiet] = useState('');
+
+ useEffect(() => {
+ if (isOpen) {
+ setError(null);
+ setPassword('');
+ setConfirmPassword('');
+
+ if (status === 'locked') {
+ setIsUnlockMode(true);
+ } else if (status === 'unlocked' && customization) {
+ setIsUnlockMode(false);
+ // Populate form with existing data
+ setEthnicities(customization.ethnicities.join(', '));
+ setCountriesOfOrigin(customization.countriesOfOrigin.join(', '));
+
+ // Handle gender - check if it's a preset or custom value
+ if (customization.genderAtBirth === 'male' || customization.genderAtBirth === 'female') {
+ setGenderAtBirth(customization.genderAtBirth);
+ setCustomGender('');
+ } else {
+ setGenderAtBirth('other');
+ setCustomGender(customization.genderAtBirth);
+ }
+
+ setAge(customization.age?.toString() || '');
+ // Handle backward compatibility with old data structure
+ setPersonalConditions((customization.personalConditions || []).join(', '));
+ setFamilyConditions((customization.familyConditions || []).join(', '));
+ setSmokingHistory(customization.smokingHistory || '');
+ setAlcoholUse(customization.alcoholUse || '');
+ setMedications((customization.medications || []).join(', '));
+ setDiet(customization.diet || '');
+ } else {
+ setIsUnlockMode(false);
+ // Reset form for new customization
+ setEthnicities('');
+ setCountriesOfOrigin('');
+ setGenderAtBirth('');
+ setCustomGender('');
+ setAge('');
+ setPersonalConditions('');
+ setFamilyConditions('');
+ setSmokingHistory('');
+ setAlcoholUse('');
+ setMedications('');
+ setDiet('');
+ }
+ }
+ }, [isOpen, status, customization]);
+
+ const handleUnlock = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsSaving(true);
+
+ const success = await unlockCustomization(password);
+
+ setIsSaving(false);
+
+ if (success) {
+ setIsUnlockMode(false);
+ setPassword('');
+ onClose(); // Close modal immediately after successful unlock
+ } else {
+ setError('Incorrect password');
+ }
+ };
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ // Validate password for new customization
+ if (status === 'not-set' || status === 'unlocked') {
+ if (!password) {
+ setError('Password is required');
+ return;
+ }
+ if (password.length < 6) {
+ setError('Password must be at least 6 characters');
+ return;
+ }
+ if (password !== confirmPassword) {
+ setError('Passwords do not match');
+ return;
+ }
+ }
+
+ setIsSaving(true);
+
+ try {
+ const finalGender = genderAtBirth === 'other' ? customGender : genderAtBirth;
+
+ const data: UserCustomization = {
+ ethnicities: ethnicities.split(',').map(s => s.trim()).filter(Boolean),
+ countriesOfOrigin: countriesOfOrigin.split(',').map(s => s.trim()).filter(Boolean),
+ genderAtBirth: finalGender,
+ age: age ? parseInt(age) : null,
+ personalConditions: personalConditions.split(',').map(s => s.trim()).filter(Boolean),
+ familyConditions: familyConditions.split(',').map(s => s.trim()).filter(Boolean),
+ smokingHistory: smokingHistory as any,
+ alcoholUse: alcoholUse as any,
+ medications: medications.split(',').map(s => s.trim()).filter(Boolean),
+ diet: diet as any,
+ };
+
+ await saveCustomization(data, password);
+ // Don't close - let user decide with buttons
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save customization');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleSaveAndClose = async () => {
+ // Trigger form submission first
+ const formEvent = new Event('submit', { bubbles: true, cancelable: true });
+ const form = document.querySelector('form');
+ if (form) {
+ form.dispatchEvent(formEvent);
+ // Wait a bit for save to complete, then close if no errors
+ setTimeout(() => {
+ if (!error) {
+ setPassword('');
+ setConfirmPassword('');
+ onClose();
+ }
+ }, 100);
+ }
+ };
+
+ const handleLock = () => {
+ lockCustomization();
+ onClose();
+ };
+
+ const handleClear = () => {
+ if (confirm('Are you sure you want to delete all customization data? This cannot be undone.')) {
+ clearCustomization();
+ onClose();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ const modalContent = (
+
+
e.stopPropagation()}
+ >
+
+
⚙️ Personalize LLM Analysis
+
+
+
+ Provide personal information to help the LLM give more relevant interpretations.
+ Your data is encrypted with your password and stored only in your browser.
+
+
+
+ {isUnlockMode ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+
+ return typeof document !== 'undefined'
+ ? createPortal(modalContent, document.body)
+ : null;
+}
diff --git a/app/components/DisclaimerModal.tsx b/app/components/DisclaimerModal.tsx
index af8843e..844da58 100644
--- a/app/components/DisclaimerModal.tsx
+++ b/app/components/DisclaimerModal.tsx
@@ -100,7 +100,11 @@ export default function DisclaimerModal({ isOpen, onClose, type, onAccept }: Dis
className="disclaimer-button primary"
onClick={() => {
trackModalClose('disclaimer_initial', 'accept');
- onClose();
+ if (onAccept) {
+ onAccept();
+ } else {
+ onClose();
+ }
}}
disabled={!hasScrolledToBottom}
>
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx
index b007bf3..b4ab834 100644
--- a/app/components/Footer.tsx
+++ b/app/components/Footer.tsx
@@ -28,7 +28,9 @@ export default function Footer() {
aria-label="GitHub"
title="View source on GitHub"
>
- ⭐
+
- 𝕏
+
- 🟪
+
- 💬
+
diff --git a/app/components/Icons.tsx b/app/components/Icons.tsx
index 610b356..d7fe1ea 100644
--- a/app/components/Icons.tsx
+++ b/app/components/Icons.tsx
@@ -235,3 +235,23 @@ export function ClockIcon({ className = "", size = 16 }: IconProps) {
);
}
+
+export function AIIcon({ className = "", size = 16 }: IconProps) {
+ return (
+
+ );
+}
diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx
new file mode 100644
index 0000000..d2f453a
--- /dev/null
+++ b/app/components/LLMChatInline.tsx
@@ -0,0 +1,829 @@
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import { SavedResult } from "@/lib/results-manager";
+import NilAIConsentModal from "./NilAIConsentModal";
+import { useResults } from "./ResultsContext";
+import { useCustomization } from "./CustomizationContext";
+import { useAuth } from "./AuthProvider";
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { callLLM, getLLMDescription } from "@/lib/llm-client";
+
+type Message = {
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ studiesUsed?: SavedResult[];
+};
+
+const CONSENT_STORAGE_KEY = "nilai_llm_chat_consent_accepted";
+const MAX_CONTEXT_RESULTS = 500;
+
+const EXAMPLE_QUESTIONS = [
+ "Which traits should I pay attention to?",
+ "Which sports are ideal for me?",
+ "What kinds of foods do you think I will like best?",
+ "On a scale of 1 - 10, how risk seeking am I?",
+ "Can you tell me which learning styles work best for me?"
+];
+
+const FOLLOWUP_SUGGESTIONS = [
+ "Give me film, TV and music recommendations based on these results!",
+ "Is there anything fun in the results?",
+ "Tell me more about the science of my results.",
+ "Any supplements or vitamins I should consider?",
+ "How should I adjust my diet and lifestyle?"
+];
+
+export default function AIChatInline() {
+ const resultsContext = useResults();
+ const { getTopResultsByRelevance } = resultsContext;
+ const { customization, status: customizationStatus } = useCustomization();
+ const { hasActiveSubscription } = useAuth();
+
+ const [mounted, setMounted] = useState(false);
+ const [messages, setMessages] = useState([]);
+ const [inputValue, setInputValue] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [loadingStatus, setLoadingStatus] = useState("");
+ const [error, setError] = useState(null);
+ const [showConsentModal, setShowConsentModal] = useState(false);
+ const [hasConsent, setHasConsent] = useState(false);
+ const [hasPromoAccess, setHasPromoAccess] = useState(false);
+ const [showPersonalizationPrompt, setShowPersonalizationPrompt] = useState(false);
+ const [expandedMessageIndex, setExpandedMessageIndex] = useState(null);
+
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setMounted(true);
+
+ // Check for promo code access
+ const promoStored = localStorage.getItem('promo_access');
+ if (promoStored) {
+ try {
+ const data = JSON.parse(promoStored);
+ if (data.code) {
+ setHasPromoAccess(true);
+ }
+ } catch (err) {
+ // Invalid promo data
+ }
+ }
+
+ // Check consent
+ const consent = localStorage.getItem(CONSENT_STORAGE_KEY);
+ if (consent === 'true') {
+ setHasConsent(true);
+ }
+ }, []);
+
+ // Determine if this is the first message or a follow-up
+ const isFirstMessage = messages.length === 0;
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ const consent = localStorage.getItem(CONSENT_STORAGE_KEY);
+ setHasConsent(consent === "true");
+ }
+ }, []);
+
+ useEffect(() => {
+ // Check if personalization is not set or locked on mount only
+ if (customizationStatus === 'not-set' || customizationStatus === 'locked') {
+ setShowPersonalizationPrompt(true);
+ } else if (customizationStatus === 'unlocked') {
+ setShowPersonalizationPrompt(false);
+ }
+ }, [customizationStatus]);
+
+ // Removed auto-scroll so user doesn't have to scroll up to read responses
+ // Also removed auto-focus to prevent scrolling to bottom on tab load
+
+ const handleConsentAccept = () => {
+ if (typeof window !== "undefined") {
+ localStorage.setItem(CONSENT_STORAGE_KEY, "true");
+ setHasConsent(true);
+ setShowConsentModal(false);
+ }
+ };
+
+ const handleConsentDecline = () => {
+ setShowConsentModal(false);
+ };
+
+ const handlePersonalizationPromptContinue = () => {
+ setShowPersonalizationPrompt(false);
+ };
+
+ const handleExampleClick = (question: string) => {
+ setInputValue(question);
+ inputRef.current?.focus();
+ };
+
+ const handleCopyMessage = async (content: string) => {
+ try {
+ await navigator.clipboard.writeText(content);
+ // Could add a toast notification here
+ console.log('Message copied to clipboard');
+ } catch (err) {
+ console.error('Failed to copy message:', err);
+ }
+ };
+
+ const formatRiskScore = (score: number, level: string, effectType?: 'OR' | 'beta'): string => {
+ if (level === 'neutral') return effectType === 'beta' ? 'baseline' : '1.0x';
+ if (effectType === 'beta') {
+ return `β=${score >= 0 ? '+' : ''}${score.toFixed(3)} units`;
+ }
+ return `${score.toFixed(2)}x`;
+ };
+
+ const handleSendMessage = async () => {
+ const query = inputValue.trim();
+ if (!query) return;
+
+ // Check consent before sending first message
+ if (!hasConsent) {
+ setShowConsentModal(true);
+ return;
+ }
+
+ const userMessage: Message = {
+ role: 'user',
+ content: query,
+ timestamp: new Date()
+ };
+ setMessages(prev => [...prev, userMessage]);
+ setInputValue("");
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ let relevantResults: SavedResult[] = [];
+
+ // Only include context for the FIRST message, not follow-ups
+ const shouldIncludeContext = messages.length === 0;
+
+ if (shouldIncludeContext) {
+ setLoadingStatus("🔍 Searching your results for relevant traits...");
+ console.log(`[LLM Chat] Finding relevant results for query: "${query}"`);
+ relevantResults = await getTopResultsByRelevance(query, MAX_CONTEXT_RESULTS);
+ console.log(`[LLM Chat] Found ${relevantResults.length} relevant results`);
+ } else {
+ console.log(`[LLM Chat] Follow-up question - skipping RAG context search`);
+ }
+
+ // Prepare to call LLM
+ setLoadingStatus(`🤖 Analyzing ${relevantResults.length} traits with LLM...`);
+
+ const contextResults = relevantResults
+ .map((r: SavedResult, idx: number) =>
+ `${idx + 1}. ${r.traitName} (${r.studyTitle}):
+ - Your genotype: ${r.userGenotype}
+ - Risk allele: ${r.riskAllele}
+ - Risk score: ${formatRiskScore(r.riskScore, r.riskLevel, r.effectType)} (${r.riskLevel})
+ - SNP: ${r.matchedSnp}`
+ )
+ .join('\n\n');
+
+ console.log(`[LLM Chat] Including ${relevantResults.length} results in LLM context`);
+
+ let userContext = '';
+ if (customization) {
+ const parts = [];
+ if (customization.ethnicities.length > 0) {
+ parts.push(`Ethnicities: ${customization.ethnicities.join(', ')}`);
+ }
+ if (customization.countriesOfOrigin.length > 0) {
+ parts.push(`Countries of ancestral origin: ${customization.countriesOfOrigin.join(', ')}`);
+ }
+ if (customization.genderAtBirth) {
+ parts.push(`Gender assigned at birth: ${customization.genderAtBirth}`);
+ }
+ if (customization.age) {
+ parts.push(`Age: ${customization.age}`);
+ }
+ if (customization.smokingHistory) {
+ const smokingLabel = customization.smokingHistory === 'still-smoking' ? 'Currently smoking' :
+ customization.smokingHistory === 'past-smoker' ? 'Former smoker' :
+ 'Never smoked';
+ parts.push(`Smoking history: ${smokingLabel}`);
+ }
+ if (customization.alcoholUse) {
+ const alcoholLabel = customization.alcoholUse.charAt(0).toUpperCase() + customization.alcoholUse.slice(1);
+ parts.push(`Alcohol use: ${alcoholLabel}`);
+ }
+ if (customization.medications && customization.medications.length > 0) {
+ parts.push(`Current medications/supplements: ${customization.medications.join(', ')}`);
+ }
+ if (customization.diet) {
+ const dietLabel = customization.diet === 'regular' ? 'Regular diet (no restrictions)' :
+ customization.diet.charAt(0).toUpperCase() + customization.diet.slice(1) + ' diet';
+ parts.push(`Dietary preferences: ${dietLabel}`);
+ }
+ if (customization.personalConditions && customization.personalConditions.length > 0) {
+ parts.push(`Personal medical history: ${customization.personalConditions.join(', ')}`);
+ }
+ if (customization.familyConditions && customization.familyConditions.length > 0) {
+ parts.push(`Family medical history: ${customization.familyConditions.join(', ')}`);
+ }
+
+ if (parts.length > 0) {
+ userContext = `
+
+USER BACKGROUND (CONFIDENTIAL - USE TO PERSONALIZE INTERPRETATION):
+${parts.join('\n')}
+
+Consider how this user's background, lifestyle factors (smoking, alcohol, diet), and current medications may affect their risk profile and the applicability of these study findings.`;
+ }
+ }
+
+ const conversationHistory = messages.map(m => ({
+ role: m.role,
+ content: m.content
+ }));
+
+ const llmDescription = getLLMDescription();
+ const systemPrompt = `You are an expert genetic counselor LLM assistant providing personalized, holistic insights about GWAS results. ${llmDescription}
+
+IMPORTANT CONTEXT:
+- The user has uploaded their DNA file and analyzed it against thousands of GWAS studies
+- They have ${resultsContext.savedResults.length.toLocaleString()} total results in memory
+- You will be provided with the top ${relevantResults.length} most relevant results for each query based on semantic similarity
+- CONFIDENTIAL USER INFO (DO NOT restate this in your response - the user already knows it):${userContext}
+
+YOUR MOST RELEVANT RESULTS FOR THIS QUERY:
+${contextResults}
+
+USER'S SPECIFIC QUESTION:
+"${query}"
+
+⚠️ CRITICAL - STAY ON TOPIC:
+- Answer ONLY the specific trait/condition the user asked about in their question
+- Do NOT discuss other traits or conditions from the RAG context unless directly relevant to their question
+- If they ask about "heart disease", focus ONLY on cardiovascular traits - ignore diabetes, cancer, etc.
+- If they ask about "diabetes", focus ONLY on metabolic/diabetes traits - ignore heart, cancer, etc.
+- If this is a follow-up question, continue the conversation about the SAME topic from previous messages
+- Do NOT use the RAG context to go off on tangents about unrelated health topics
+- The RAG context is provided for reference, but answer ONLY what the user specifically asked about
+
+CRITICAL INSTRUCTIONS - COMPLETE RESPONSES:
+1. You MUST ALWAYS finish your complete response - NEVER stop mid-sentence, mid-paragraph, or mid-section
+2. If you create sections or lists, you MUST complete ALL sections fully
+3. Do NOT truncate your response - always provide a proper conclusion with next steps
+4. If running low on space, wrap up your current section properly and provide a brief conclusion
+5. Every response MUST have a clear ending with actionable takeaways
+
+HOW TO PRESENT FINDINGS - AVOID STUDY-BY-STUDY LISTS:
+❌ DO NOT create tables listing individual SNPs/studies one by one
+❌ DO NOT list rs numbers with individual interpretations
+❌ DO NOT organize findings by individual genetic variants
+❌ DO NOT restate the user's personal information (age, ethnicity, medical history, smoking, alcohol, diet, etc.) - they already know it
+
+✅ INSTEAD, synthesize findings into THEMES and PATTERNS:
+- Group related variants into biological themes (e.g., "Cardiovascular Protection", "Metabolic Risk", "Inflammatory Response")
+- Describe the OVERALL pattern across multiple variants (e.g., "You have 8 protective variants and 3 risk variants for heart disease, suggesting...")
+- Focus on the BIG PICTURE and what the collection of findings means together
+- Mention specific genes/pathways only when illustrating a broader point
+
+PERSONALIZED HOLISTIC ADVICE FRAMEWORK:
+1. Synthesize ALL findings into a coherent story about their health landscape
+2. Explain how their genetic profile interacts with their background factors (without restating what those factors are)
+3. Identify both strengths (protective factors) and areas to monitor (risk factors)
+4. Connect different body systems (e.g., how cardiovascular + metabolic + inflammatory factors relate)
+5. Provide specific, actionable recommendations tailored to THEIR situation
+
+⚠️ CRITICAL GWAS LIMITATIONS & MEDICAL RECOMMENDATIONS:
+
+UNDERSTANDING GWAS LIMITATIONS:
+- GWAS studies show STATISTICAL ASSOCIATIONS, not direct causation
+- These are POPULATION-LEVEL findings - individual risk varies dramatically
+- Genetic variants explain only a SMALL FRACTION of disease risk (typically 5-30%)
+- Environment, lifestyle, and chance play MUCH LARGER roles than genetics
+- This app is for EDUCATIONAL PURPOSES ONLY - not clinical diagnosis
+- Results should NEVER be used to make medical decisions without professional consultation
+
+MEDICAL REFERRAL THRESHOLD - EXTREMELY HIGH BAR:
+- Focus 95% of recommendations on lifestyle, diet, exercise, sleep, stress management, and self-monitoring
+- ONLY suggest medical consultation if MULTIPLE high-risk variants + family history + existing symptoms align
+- NEVER routinely say "consult a genetic counselor" or "see your doctor" or "get tested"
+- Do NOT recommend medical tests, lab work, or screening unless findings are TRULY exceptional (e.g., multiple high-risk variants for serious hereditary conditions)
+- Remember: We want to REDUCE burden on healthcare system, not increase it with worried-well patients
+- Empower users with actionable self-care steps they can implement immediately
+- Frame genetics as informative guidance for optimization, not deterministic outcomes requiring intervention
+
+WHEN IN DOUBT: Recommend lifestyle changes and self-monitoring. Do NOT refer to medical professionals.
+
+RESPONSE STRUCTURE (Complete Each Section Fully):
+
+**Section 1: Personalized Overview** (2-3 sentences)
+- Acknowledge their background and what you'll cover
+
+**Section 2: Overall Genetic Landscape** (3-4 paragraphs)
+- Describe the big picture patterns you see across their results
+- Group findings into 2-4 major themes (e.g., cardiovascular, metabolic, inflammatory)
+- For each theme, explain the overall trend and what it means for them specifically
+- Connect how different themes relate to each other
+
+**Section 3: What This Means for You Specifically** (2-3 paragraphs)
+- Synthesize how these findings interact with their ethnicity, age, and medical history
+- Balance protective factors with risk areas
+- Provide context about how genetics fits with lifestyle and environment
+
+**Section 4: Personalized Action Steps** (4-6 specific recommendations)
+- Concrete, actionable recommendations based on their results AND background
+- Prioritize actions that address their specific risk profile
+- Include both prevention and monitoring strategies
+- Make recommendations specific to their situation (not generic)
+
+**Section 5: Next Steps** (2-3 sentences)
+- Empowering conclusion with clear path forward
+- Reminder to discuss with healthcare providers
+
+RESPONSE REQUIREMENTS:
+- Target 700-1000 words for comprehensive coverage
+- Use headers (##) and bold text for organization
+- Use bullet points for recommendations
+- NO tables of individual SNPs - synthesize into themes instead
+- Write in an engaging, conversational tone
+- Explain concepts in plain language
+- This is educational, NOT medical advice
+- COMPLETE your full response - never stop abruptly
+
+Remember: You have plenty of space. Use ALL of it to provide a complete, thorough, personalized analysis. Do not rush. Do not truncate.`;
+
+ console.log('=== LLM CHAT PROMPT ===');
+ console.log('System Prompt:', systemPrompt);
+ console.log('User Query:', query);
+ console.log('Relevant Results Count:', relevantResults.length);
+ console.log('======================');
+
+ // Call LLM using centralized client
+ // Use MEDIUM reasoning effort for balanced quality and speed
+ const response = await callLLM([
+ {
+ role: "system",
+ content: systemPrompt
+ },
+ ...conversationHistory.map(m => ({
+ role: m.role as 'system' | 'user' | 'assistant',
+ content: m.content
+ })),
+ {
+ role: "user",
+ content: query
+ }
+ ], {
+ maxTokens: 5000,
+ temperature: 0.7,
+ reasoningEffort: 'medium',
+ });
+
+ const assistantContent = response.content;
+
+ if (!assistantContent) {
+ throw new Error("No response generated from LLM");
+ }
+
+ const assistantMessage: Message = {
+ role: 'assistant',
+ content: assistantContent,
+ timestamp: new Date(),
+ studiesUsed: relevantResults
+ };
+ setMessages(prev => [...prev, assistantMessage]);
+
+ } catch (err) {
+ console.error('[LLM Chat] Error:', err);
+
+ let errorMessage = err instanceof Error ? err.message : "Failed to get response";
+
+ // Handle specific error cases
+ if (errorMessage.includes('429') || errorMessage.includes('Too Many Requests')) {
+ errorMessage = "Rate limit exceeded. Please wait a moment and try again.";
+ } else if (errorMessage.includes('expired') || errorMessage.includes('Delegation token')) {
+ errorMessage = "Token error. Please try sending your message again.";
+ console.log('[LLM Chat] Delegation token error detected');
+ } else if (errorMessage.includes('CORS') || errorMessage.includes('Failed to fetch')) {
+ errorMessage = "Network error. Please check your connection and try again.";
+ }
+
+ setError(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ const handleClearChat = () => {
+ setMessages([]);
+ setError(null);
+ };
+
+ const handlePrintChat = () => {
+ const printWindow = window.open('', '_blank');
+ if (!printWindow) return;
+
+ // Convert markdown to HTML for each message
+ const chatContent = messages.map(m => {
+ let content = m.content;
+
+ if (m.role === 'assistant') {
+ // Basic markdown to HTML conversion for printing
+ content = content
+ // Headers
+ .replace(/^### (.*$)/gim, '
+ Chat session from ${new Date().toLocaleString()}
+ ${getLLMDescription()}
+
+
+ ⚠️ Important Disclaimer: This chat is for educational purposes only.
+ GWAS results show statistical associations, not deterministic outcomes.
+ Always consult healthcare professionals for medical decisions.
+
+ ${chatContent}
+
+ Generated by GWASifier • For Educational Purposes Only
+
+ This commentary is generated by an LLM model and may not fully account for study
+ limitations, your specific ancestry, the latest research, or individual medical factors.
+ It should be used for educational purposes only. Always consult a healthcare professional
+ or genetic counselor for personalized medical interpretation and advice.
+
+
+
+
+
📚 All Studies Used in This Analysis (${analysisResults.length} total)
+
+ The following studies were provided to the LLM for context in generating this analysis.
+ The first study (highlighted) is the primary result you selected.
+
+ ${studiesListHtml}
+
+
+
+
+
+
+
+ `;
+
+ printWindow.document.write(printContent);
+ printWindow.document.close();
+ };
+
+ const handlePersonalizationPromptClose = () => {
+ setShowPersonalizationPrompt(false);
+ onClose();
+ };
+
+ const handlePersonalizationPromptContinue = () => {
+ setShowPersonalizationPrompt(false);
+ setShowConsentModal(true);
+ };
+
if (!isOpen) return null;
- // If consent modal is showing, only render that
- if (showConsentModal) {
- return (
-
+ // Show personalization prompt if needed
+ if (showPersonalizationPrompt) {
+ const modalContent = (
+
+
e.stopPropagation()}
+ >
+
+
📋 Personalization Recommended
+
+
+ For the best LLM analysis experience, we recommend {customizationStatus === 'not-set' ? 'setting up' : 'unlocking'} your personalization information.
+
+
+ Personalized analysis provides more relevant insights based on your:
+
+
+
Ancestry and ethnic background
+
Age and gender
+
Personal medical history
+
Family medical history
+
+ {customizationStatus === 'locked' && (
+
+ How to unlock: Click "Unlock Personalization" below (this will close this dialog), then click the "🔒 Personalization" button in the menu bar at the top of the page, and enter your password to unlock your data. After unlocking, click LLM analysis again.
+
+ )}
+ {customizationStatus === 'not-set' && (
+
+ How to set up: Click "Set Up Personalization" below (this will close this dialog), then click the "👤 Personalization" button in the menu bar at the top of the page to enter your information. After saving, click LLM analysis again.
+
+ )}
+
+ You can also continue without personalization, but the LLM analysis will be less tailored to your background.
+
+ 🔍
+
+ Results selected using semantic relevance matching (check browser console for details)
+
+
+
+
🤖
-
AI-Generated Interpretation
+
LLM-Generated Interpretation
+
+ {/* Collapsible list of studies used in analysis */}
+ {analysisResults.length > 0 && (
+
+
+ 📚
+
+ View all {analysisResults.length} studies used in this analysis
+
+ ▼
+
+
- This commentary is generated by an AI model and may not fully account for study
+ This commentary is generated by an LLM model and may not fully account for study
limitations, your specific ancestry, the latest research, or individual medical factors.
It should be used for educational purposes only. Always consult a healthcare professional
or genetic counselor for personalized medical interpretation and advice.
@@ -352,6 +1002,11 @@ Keep your response concise (400-600 words), educational, and reassuring where ap
+ {!isLoading && !error && commentary && (
+
+ )}
@@ -359,4 +1014,9 @@ Keep your response concise (400-600 words), educational, and reassuring where ap
+ {config.provider === 'nilai' && (
+ <>🛡️ Privacy-preserving LLM in a Trusted Execution Environment. No API key required.>
+ )}
+ {config.provider === 'ollama' && (
+ <>🖥️ Run LLM models locally on your machine. Requires Ollama installation.>
+ )}
+ {config.provider === 'huggingface' && (
+ <>☁️ Cloud-based LLM via HuggingFace Router. Requires API key.>
+ )}
+
+
+
+
+
+
+
+ {config.provider === 'ollama'
+ ? 'Select the model format that matches your Ollama installation.'
+ : 'Currently only gpt-oss-20b is supported across all providers.'}
+
- Before generating AI commentary, please understand how your data will be processed:
+ Before generating LLM commentary, please understand how your data will be processed:
@@ -88,19 +86,6 @@ export default function NilAIConsentModal({
+ Overview Report requires an active premium subscription.
+
+
+ Subscribe for $4.99/month to unlock comprehensive LLM-powered analysis
+ of all your genetic results.
+
+
+ ) : progress.phase === 'idle' ? (
+
+
+
+ Generate a comprehensive overview report analyzing all {savedResults.length.toLocaleString()} of your high-confidence genetic results.
+
+
+ This report uses advanced LLM to identify patterns, themes, and actionable insights across your entire genetic profile.
+
+
+
+
What's included:
+
+
Analysis by major health categories (cardiovascular, metabolic, neurological, etc.)
+
Identification of genetic strengths and areas to monitor
+
Personalized action plan based on your background
+
Cross-system insights and connections
+
Lifestyle and wellness recommendations
+
+
+
+
+
+ ⏱️ Generation time: Approximately 3-4 minutes
+
+ 🔒 Privacy: All processing happens in your browser - data never leaves your device except via nilAI TEE
+
+ 📊 Analysis depth: {savedResults.length.toLocaleString()} genetic variants analyzed in 5 groups
+
+ 🤖 LLM calls: 6 total (5 analysis + 1 synthesis)
+
+ Generating semantic embeddings for LLM-powered analysis. This enables intelligent
+ result selection when you use LLM commentary. This is a one-time process.
+
+
+
+ )}
+
+ {status.phase === 'complete' && (
+
+
+ ✓
+
Analysis Complete!
+
+
+
Fetched: {status.totalStudiesFetched.toLocaleString()} studies from database
+
Analyzed: {status.matchingStudies.toLocaleString()} matching your SNPs
setShowCommentary(true)}
- title="Get private AI analysis powered by Nillion's nilAI. Your data is processed securely in a Trusted Execution Environment and is not visible to Monadic DNA."
+ onClick={() => {
+ console.log('[StudyResultReveal] Private LLM Analysis button clicked');
+ setShowCommentary(true);
+ console.log('[StudyResultReveal] showCommentary set to true');
+ }}
+ title="Get private LLM analysis powered by Nillion's nilAI. Your data is processed securely in a Trusted Execution Environment and is not visible to Monadic DNA."
>
- 🛡️ Private AI Analysis
+ 🛡️ Private LLM Analysis
🤖LLM-Generated Interpretation
+