sachin1801 commited on
Commit
923926c
·
1 Parent(s): b7ec4a2

added api routes, changes to input pages, db creationg + token mapping, history page created, batch support for csv + fasta files

Browse files
.claude/skills/agent-log.md CHANGED
@@ -403,6 +403,253 @@ python -m uvicorn webapp.app.main:app --port 8000
403
 
404
  ---
405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  ## Future Sessions
407
 
408
  _Sessions will be logged here as work progresses._
 
403
 
404
  ---
405
 
406
+ ## Session 4 - 2026-01-15
407
+
408
+ ### Session Start
409
+ - **Task**: Plan and implement multi-sequence input, file upload, token-based history, enhanced batch results
410
+ - **Status**: COMPLETE
411
+
412
+ ### Requirements Gathered Through Q&A
413
+
414
+ | Requirement | Decision |
415
+ |-------------|----------|
416
+ | Input text format | Support both FASTA and plain sequences (auto-detect) |
417
+ | User identification | Token-based: auto-generate + allow user edit (NAR compliant) |
418
+ | Auto job title format | `2026-01-15_abc12` (date + 5-char random ID) |
419
+ | Token change behavior | Old jobs keep old token (no migration) |
420
+ | CSV delimiter | Auto-detect (comma, semicolon, tab) |
421
+ | Email notifications | Not for now |
422
+ | History search | Job title + date range filter |
423
+ | Results pagination | 25 per page |
424
+ | Invalid sequences | Mark with "Invalid" badge (no details shown) |
425
+ | Visualizations in dropdown | All (force plot, RNA structure, PSI gauge, sequence logo) |
426
+ | Max batch size | 100 sequences (kept same) |
427
+
428
+ ### NAR Compliance Analysis
429
+
430
+ User asked about NAR guidelines for user identification without authentication. Researched and confirmed:
431
+
432
+ **NAR Requirements:**
433
+ - No login/registration required (token is auto-generated)
434
+ - Bookmarkable result URLs (`/result/{job_id}`)
435
+ - User data private (only accessible via token/URL)
436
+ - No tracking cookies (localStorage is allowed)
437
+
438
+ **Token System Solution:**
439
+ - Token format: `tok_xxxxxxxxxxxx` (12 random alphanumeric)
440
+ - Auto-generated on first visit, stored in localStorage
441
+ - User can edit/update token anytime
442
+ - Old jobs keep old token when user changes it
443
+ - Jobs searchable by token on `/history` page
444
+
445
+ ### Work Completed
446
+
447
+ #### 1. Database Schema Changes (`webapp/app/models/job.py`)
448
+ - Added `access_token` column (String(64), indexed)
449
+ - Added `job_title` column (String(255))
450
+ - Added new indexes: `idx_jobs_access_token`, `idx_jobs_created_at`
451
+ - Updated `batch_sequences` to store named sequences: `[{name, sequence}, ...]`
452
+
453
+ #### 2. API Schema Updates (`webapp/app/api/schemas.py`)
454
+ - Added `SequenceItem` schema (name + sequence)
455
+ - Updated `BatchSequenceInput` to accept `List[SequenceItem]`
456
+ - Added `access_token`, `job_title`, `name` to `SequenceInput`
457
+ - Added `JobSummary` and `JobHistoryResponse` for history endpoint
458
+ - Added `PaginatedBatchResultsResponse` with stats (total, successful_count, invalid_count, average_psi)
459
+ - Added `SequenceDetailResponse` for single sequence details
460
+ - Added `BatchResultItem` with `index` field for pagination
461
+ - Added `validate_single_sequence()` function for graceful validation
462
+
463
+ #### 3. New API Endpoints (`webapp/app/api/routes.py`)
464
+ - `GET /api/history` - Paginated job history by token with search & date filters
465
+ - `DELETE /api/jobs/{job_id}` - Delete job (token verified)
466
+ - `GET /api/batch/{job_id}/results` - Paginated batch results with search
467
+ - `GET /api/batch/{job_id}/sequence/{index}` - Single sequence detail with force plot
468
+ - Added `generate_job_title()` helper for auto job title generation
469
+ - Modified `/api/predict` and `/api/batch` to handle access_token and job_title
470
+ - Batch processing validates each sequence individually, marks invalid ones
471
+
472
+ #### 4. New JavaScript Files (`webapp/static/js/`)
473
+
474
+ | File | Purpose | Key Functions |
475
+ |------|---------|---------------|
476
+ | `token.js` | Token management | `generateToken()`, `getOrCreateToken()`, `setToken()`, `copyTokenToClipboard()`, `initTokenDisplay()` |
477
+ | `file-parser.js` | CSV/FASTA parsing | `parseFasta()`, `parseCSV()`, `parseFile()`, `detectDelimiter()`, `detectHeader()`, `validateSequence()` |
478
+ | `history.js` | History page | `loadJobs()`, `renderJobs()`, `renderPagination()`, `deleteJob()`, `applyFilters()` |
479
+ | `batch-result.js` | Batch results | `loadResults()`, `renderResults()`, `showDetail()`, `createForcePlot()`, `updateStats()` |
480
+
481
+ #### 5. New Templates (`webapp/templates/`)
482
+
483
+ | File | Description |
484
+ |------|-------------|
485
+ | `history.html` | Job history page with search, date filters, paginated table |
486
+ | `batch_result.html` | Batch results with summary stats, search, pagination, detail modal |
487
+
488
+ #### 6. Template Updates
489
+
490
+ - **`index.html`**: Redesigned with token display, job title field, multi-sequence textarea, file upload button, sequence count display
491
+ - **`base.html`**: Added "History" link to desktop and mobile navigation
492
+
493
+ #### 7. Route Updates (`webapp/app/main.py`)
494
+ - Added `/history` route
495
+ - Updated `/result/{job_id}` to detect batch jobs and render `batch_result.html`
496
+
497
+ ### Files Created/Modified
498
+
499
+ ```
500
+ webapp/
501
+ ├── app/
502
+ │ ├── main.py # Modified: Added history route, batch detection
503
+ │ ├── api/
504
+ │ │ ├── routes.py # Modified: New endpoints, batch validation
505
+ │ │ └── schemas.py # Modified: New schemas for history/batch
506
+ │ └── models/
507
+ │ └── job.py # Modified: access_token, job_title fields
508
+ ├── static/js/
509
+ │ ├── token.js # NEW: Token management
510
+ │ ├── file-parser.js # NEW: CSV/FASTA parsing
511
+ │ ├── history.js # NEW: History page logic
512
+ │ └── batch-result.js # NEW: Batch results page logic
513
+ └── templates/
514
+ ├── base.html # Modified: Added History nav link
515
+ ├── index.html # Modified: Multi-sequence input, file upload
516
+ ├── history.html # NEW: Job history page
517
+ └── batch_result.html # NEW: Batch results page
518
+ ```
519
+
520
+ ### API Endpoints Summary
521
+
522
+ | Method | Endpoint | Description |
523
+ |--------|----------|-------------|
524
+ | POST | `/api/predict` | Single sequence (now with token/title) |
525
+ | POST | `/api/batch` | Batch sequences with named items |
526
+ | GET | `/api/history` | Paginated job history by token |
527
+ | DELETE | `/api/jobs/{job_id}` | Delete job (token verified) |
528
+ | GET | `/api/batch/{job_id}/results` | Paginated batch results |
529
+ | GET | `/api/batch/{job_id}/sequence/{index}` | Single sequence detail |
530
+
531
+ ### Plan File
532
+ See: `/Users/sachin/.claude/plans/structured-crafting-mitten.md`
533
+
534
+ ### How to Run
535
+ ```bash
536
+ source venv310/bin/activate
537
+ python -m uvicorn webapp.app.main:app --port 8000
538
+ # Open http://localhost:8000
539
+ ```
540
+
541
+ ### Testing Checklist
542
+ See testing instructions below in Session Notes.
543
+
544
+ ---
545
+
546
+ ## Session 5 - 2026-01-15
547
+
548
+ ### Session Start
549
+ - **Task**: Implement PyShiny Filter × Position Heatmap Visualization
550
+ - **Status**: COMPLETE
551
+
552
+ ### User Request
553
+ User wanted to understand what "filters/features" mean in the model and implement a heatmap visualization (like the screenshot provided) showing filter activations across sequence positions using PyShiny.
554
+
555
+ ### What "Filters" Mean - Explanation Provided
556
+
557
+ The CNN model has **56 convolutional filters** that act as pattern detectors:
558
+
559
+ | Filter Type | Count | Purpose |
560
+ |-------------|-------|---------|
561
+ | `qc_incl` (incl_1 to incl_20) | 20 | Detect sequence patterns promoting **inclusion** |
562
+ | `qc_skip` (skip_1 to skip_20) | 20 | Detect sequence patterns promoting **skipping** |
563
+ | `c_incl_struct` (incl_struct_1-8) | 8 | Detect structure patterns for inclusion |
564
+ | `c_skip_struct` (skip_struct_1-8) | 8 | Detect structure patterns for skipping |
565
+
566
+ **Heatmap interpretation**:
567
+ - Rows = Filter names
568
+ - Columns = Positions in sequence (1-90)
569
+ - Color intensity = Activation strength (brighter = stronger pattern detection)
570
+ - Bright spots indicate where a filter detected its learned pattern
571
+
572
+ ### Work Completed
573
+
574
+ #### 1. Added PyShiny Dependency
575
+ **File**: `webapp/requirements.txt`
576
+ ```
577
+ shiny>=0.8.0 # PyShiny for interactive visualizations
578
+ ```
579
+
580
+ #### 2. Backend - Heatmap Data Extraction
581
+ **File**: `webapp/app/services/predictor.py`
582
+
583
+ Added `get_heatmap_data()` method (~65 lines) that:
584
+ - Extracts activations from all 4 convolutional layers
585
+ - Applies ReLU activation
586
+ - Pads activations to align with 90 positions
587
+ - Returns structured data: positions, nucleotides, filter_names, activations (56×90 matrix)
588
+
589
+ #### 3. API Endpoint
590
+ **File**: `webapp/app/api/routes.py`
591
+
592
+ Added: `GET /api/heatmap/{job_id}`
593
+ - Returns filter activation data for a completed job
594
+ - Response includes 56 filters × 90 positions
595
+
596
+ #### 4. PyShiny Heatmap App
597
+ **New file**: `webapp/app/shiny_apps/heatmap_app.py` (~250 lines)
598
+
599
+ Created interactive PyShiny app with:
600
+ - Left panel: Filter checkboxes (grouped by inclusion/skipping/structure)
601
+ - Main area: Plotly heatmap with viridis colorscale
602
+ - Hover info: Position, nucleotide, filter name, activation value
603
+ - Select All / Deselect All buttons
604
+
605
+ #### 5. Mount PyShiny in FastAPI
606
+ **File**: `webapp/app/main.py`
607
+
608
+ - Added PyShiny import with graceful fallback
609
+ - Mounted heatmap app at `/shiny/heatmap/`
610
+
611
+ #### 6. Embed in Result Page
612
+ **File**: `webapp/templates/result.html`
613
+
614
+ Added heatmap section below force plot with iframe.
615
+
616
+ ### Files Modified
617
+
618
+ | File | Changes |
619
+ |------|---------|
620
+ | `webapp/requirements.txt` | Added `shiny>=0.8.0` |
621
+ | `webapp/app/services/predictor.py` | Added `get_heatmap_data()` method |
622
+ | `webapp/app/api/routes.py` | Added `/api/heatmap/{job_id}` endpoint |
623
+ | `webapp/app/main.py` | PyShiny import + mount at `/shiny/heatmap/` |
624
+ | `webapp/templates/result.html` | Added heatmap iframe section |
625
+
626
+ ### Files Created
627
+
628
+ | File | Description |
629
+ |------|-------------|
630
+ | `webapp/app/shiny_apps/__init__.py` | Package init |
631
+ | `webapp/app/shiny_apps/heatmap_app.py` | PyShiny heatmap application |
632
+
633
+ ### Testing Results
634
+
635
+ ```
636
+ Heatmap API Response:
637
+ Positions: 90
638
+ Filter names: 56
639
+ Activations matrix: 56 filters x 90 positions
640
+ SUCCESS: Heatmap API is working!
641
+ ```
642
+
643
+ ### How to Test (See detailed instructions below)
644
+
645
+ ```bash
646
+ source venv310/bin/activate
647
+ python -m uvicorn webapp.app.main:app --port 8000
648
+ # Open http://localhost:8000
649
+ ```
650
+
651
+ ---
652
+
653
  ## Future Sessions
654
 
655
  _Sessions will be logged here as work progresses._
=0.8.0 ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Requirement already satisfied: shiny in ./venv310/lib/python3.10/site-packages (1.5.1)
2
+ Requirement already satisfied: typing-extensions>=4.10.0 in ./venv310/lib/python3.10/site-packages (from shiny) (4.15.0)
3
+ Requirement already satisfied: uvicorn>=0.16.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.40.0)
4
+ Requirement already satisfied: starlette in ./venv310/lib/python3.10/site-packages (from shiny) (0.50.0)
5
+ Requirement already satisfied: websockets>=13.0 in ./venv310/lib/python3.10/site-packages (from shiny) (16.0)
6
+ Requirement already satisfied: htmltools>=0.6.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.6.0)
7
+ Requirement already satisfied: click>=8.1.4 in ./venv310/lib/python3.10/site-packages (from shiny) (8.3.1)
8
+ Requirement already satisfied: markdown-it-py>=1.1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (4.0.0)
9
+ Requirement already satisfied: mdit-py-plugins>=0.3.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.5.0)
10
+ Requirement already satisfied: linkify-it-py>=1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (2.0.3)
11
+ Requirement already satisfied: platformdirs>=2.1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (4.5.1)
12
+ Requirement already satisfied: asgiref>=3.5.2 in ./venv310/lib/python3.10/site-packages (from shiny) (3.11.0)
13
+ Requirement already satisfied: packaging>=20.9 in ./venv310/lib/python3.10/site-packages (from shiny) (25.0)
14
+ Requirement already satisfied: watchfiles>=0.18.0 in ./venv310/lib/python3.10/site-packages (from shiny) (1.1.1)
15
+ Requirement already satisfied: questionary>=2.0.0 in ./venv310/lib/python3.10/site-packages (from shiny) (2.1.1)
16
+ Requirement already satisfied: prompt-toolkit in ./venv310/lib/python3.10/site-packages (from shiny) (3.0.52)
17
+ Requirement already satisfied: python-multipart>=0.0.7 in ./venv310/lib/python3.10/site-packages (from shiny) (0.0.21)
18
+ Requirement already satisfied: narwhals>=1.10.0 in ./venv310/lib/python3.10/site-packages (from shiny) (2.15.0)
19
+ Requirement already satisfied: orjson>=3.10.7 in ./venv310/lib/python3.10/site-packages (from shiny) (3.11.5)
20
+ Requirement already satisfied: shinychat>=0.1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.2.8)
21
+ Requirement already satisfied: uc-micro-py in ./venv310/lib/python3.10/site-packages (from linkify-it-py>=1.0->shiny) (1.0.3)
22
+ Requirement already satisfied: mdurl~=0.1 in ./venv310/lib/python3.10/site-packages (from markdown-it-py>=1.1.0->shiny) (0.1.2)
23
+ Requirement already satisfied: wcwidth in ./venv310/lib/python3.10/site-packages (from prompt-toolkit->shiny) (0.2.14)
24
+ Requirement already satisfied: h11>=0.8 in ./venv310/lib/python3.10/site-packages (from uvicorn>=0.16.0->shiny) (0.16.0)
25
+ Requirement already satisfied: anyio>=3.0.0 in ./venv310/lib/python3.10/site-packages (from watchfiles>=0.18.0->shiny) (4.12.1)
26
+ Requirement already satisfied: exceptiongroup>=1.0.2 in ./venv310/lib/python3.10/site-packages (from anyio>=3.0.0->watchfiles>=0.18.0->shiny) (1.3.1)
27
+ Requirement already satisfied: idna>=2.8 in ./venv310/lib/python3.10/site-packages (from anyio>=3.0.0->watchfiles>=0.18.0->shiny) (3.11)
webapp/app/api/routes.py CHANGED
@@ -2,12 +2,15 @@
2
 
3
  import uuid
4
  import json
 
 
5
  from datetime import datetime, timedelta
6
- from typing import Optional
 
7
  from fastapi import APIRouter, Depends, HTTPException, Query, Path
8
  from fastapi.responses import Response
9
  from sqlalchemy.orm import Session
10
- from sqlalchemy import text
11
 
12
  from webapp.app.database import get_db
13
  from webapp.app.models.job import Job
@@ -16,6 +19,7 @@ from webapp.app.config import settings
16
  from webapp.app.api.schemas import (
17
  SequenceInput,
18
  BatchSequenceInput,
 
19
  PredictionResponse,
20
  JobStatusResponse,
21
  SingleResultResponse,
@@ -25,8 +29,20 @@ from webapp.app.api.schemas import (
25
  ExampleSequencesResponse,
26
  HealthResponse,
27
  ErrorResponse,
 
 
 
 
 
28
  )
29
 
 
 
 
 
 
 
 
30
  router = APIRouter()
31
 
32
 
@@ -67,6 +83,9 @@ async def submit_prediction(
67
  """
68
  job_id = str(uuid.uuid4())
69
 
 
 
 
70
  # Create job in database
71
  job = Job(
72
  id=job_id,
@@ -74,6 +93,8 @@ async def submit_prediction(
74
  sequence=request.sequence,
75
  email=request.email,
76
  is_batch=False,
 
 
77
  )
78
  db.add(job)
79
  db.commit()
@@ -124,19 +145,31 @@ async def submit_batch_prediction(
124
  Submit multiple sequences for batch PSI prediction.
125
 
126
  Each sequence must be exactly 70 nucleotides long and contain only A, C, G, T.
 
127
  Maximum batch size is 100 sequences.
128
  """
129
  job_id = str(uuid.uuid4())
130
 
 
 
 
 
 
 
 
 
 
131
  # Create job in database
132
  job = Job(
133
  id=job_id,
134
  status="queued",
135
- sequence=request.sequences[0], # Store first sequence as reference
136
  email=request.email,
137
  is_batch=True,
 
 
138
  )
139
- job.set_batch_sequences(request.sequences)
140
  db.add(job)
141
  db.commit()
142
 
@@ -146,7 +179,51 @@ async def submit_batch_prediction(
146
  db.commit()
147
 
148
  predictor = get_predictor()
149
- results = predictor.predict_batch(request.sequences)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  # Update job with results
152
  job.status = "finished"
@@ -159,12 +236,17 @@ async def submit_batch_prediction(
159
  db.commit()
160
  raise HTTPException(status_code=500, detail=str(e))
161
 
 
 
 
 
 
162
  return PredictionResponse(
163
  job_id=job_id,
164
  status=job.status,
165
  status_url=f"/api/status/{job_id}",
166
  result_url=f"/result/{job_id}",
167
- message=f"Batch prediction completed for {len(request.sequences)} sequences",
168
  )
169
 
170
 
@@ -223,26 +305,32 @@ async def get_job_result(
223
  # Return batch results
224
  results = job.get_batch_results()
225
  successful = sum(1 for r in results if r.get("status") == "success")
226
- failed = len(results) - successful
 
227
 
228
  return BatchResultResponse(
229
  job_id=job.id,
 
230
  status=job.status,
231
  total_sequences=len(results),
232
  successful=successful,
 
233
  failed=failed,
234
  results=[
235
  BatchResultItem(
 
236
  sequence=r.get("sequence", ""),
237
  status=r.get("status", "unknown"),
238
  psi=r.get("psi"),
239
  interpretation=r.get("interpretation"),
240
  structure=r.get("structure"),
241
  mfe=r.get("mfe"),
 
 
242
  error=r.get("error"),
243
  warnings=r.get("warnings"),
244
  )
245
- for r in results
246
  ],
247
  created_at=job.created_at,
248
  expires_at=job.expires_at,
@@ -267,6 +355,34 @@ async def get_job_result(
267
  )
268
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  @router.get("/example", response_model=ExampleSequencesResponse, tags=["examples"])
271
  async def get_example_sequences():
272
  """
@@ -349,16 +465,18 @@ async def export_results(
349
 
350
  if job.is_batch:
351
  results = job.get_batch_results()
352
- header = ["sequence", "psi", "interpretation", "structure", "mfe", "status", "error"]
353
  rows = [delimiter.join(header)]
354
- for r in results:
355
  row = [
 
356
  r.get("sequence", ""),
357
  str(r.get("psi", "")),
358
  r.get("interpretation", ""),
359
  r.get("structure", ""),
360
  str(r.get("mfe", "")),
361
  r.get("status", ""),
 
362
  r.get("error", ""),
363
  ]
364
  rows.append(delimiter.join(row))
@@ -382,3 +500,229 @@ async def export_results(
382
  )
383
 
384
  raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import uuid
4
  import json
5
+ import string
6
+ import random
7
  from datetime import datetime, timedelta
8
+ from typing import Optional, List
9
+ from math import ceil
10
  from fastapi import APIRouter, Depends, HTTPException, Query, Path
11
  from fastapi.responses import Response
12
  from sqlalchemy.orm import Session
13
+ from sqlalchemy import text, and_, or_
14
 
15
  from webapp.app.database import get_db
16
  from webapp.app.models.job import Job
 
19
  from webapp.app.api.schemas import (
20
  SequenceInput,
21
  BatchSequenceInput,
22
+ SequenceItem,
23
  PredictionResponse,
24
  JobStatusResponse,
25
  SingleResultResponse,
 
29
  ExampleSequencesResponse,
30
  HealthResponse,
31
  ErrorResponse,
32
+ JobSummary,
33
+ JobHistoryResponse,
34
+ PaginatedBatchResultsResponse,
35
+ SequenceDetailResponse,
36
+ validate_single_sequence,
37
  )
38
 
39
+
40
+ def generate_job_title() -> str:
41
+ """Generate an auto job title in format: 2026-01-15_abc12"""
42
+ date_part = datetime.utcnow().strftime("%Y-%m-%d")
43
+ random_part = ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
44
+ return f"{date_part}_{random_part}"
45
+
46
  router = APIRouter()
47
 
48
 
 
83
  """
84
  job_id = str(uuid.uuid4())
85
 
86
+ # Generate job title if not provided
87
+ job_title = request.job_title if request.job_title else generate_job_title()
88
+
89
  # Create job in database
90
  job = Job(
91
  id=job_id,
 
93
  sequence=request.sequence,
94
  email=request.email,
95
  is_batch=False,
96
+ access_token=request.access_token,
97
+ job_title=job_title,
98
  )
99
  db.add(job)
100
  db.commit()
 
145
  Submit multiple sequences for batch PSI prediction.
146
 
147
  Each sequence must be exactly 70 nucleotides long and contain only A, C, G, T.
148
+ Invalid sequences will be marked in results but won't block processing of valid ones.
149
  Maximum batch size is 100 sequences.
150
  """
151
  job_id = str(uuid.uuid4())
152
 
153
+ # Generate job title if not provided
154
+ job_title = request.job_title if request.job_title else generate_job_title()
155
+
156
+ # Convert sequences to dict format for storage
157
+ sequences_for_storage = [
158
+ {"name": seq.name, "sequence": seq.sequence}
159
+ for seq in request.sequences
160
+ ]
161
+
162
  # Create job in database
163
  job = Job(
164
  id=job_id,
165
  status="queued",
166
+ sequence=request.sequences[0].sequence, # Store first sequence as reference
167
  email=request.email,
168
  is_batch=True,
169
+ access_token=request.access_token,
170
+ job_title=job_title,
171
  )
172
+ job.set_batch_sequences(sequences_for_storage)
173
  db.add(job)
174
  db.commit()
175
 
 
179
  db.commit()
180
 
181
  predictor = get_predictor()
182
+ results = []
183
+
184
+ for seq_item in request.sequences:
185
+ # Validate each sequence
186
+ is_valid, validation_error = validate_single_sequence(seq_item.sequence)
187
+
188
+ if not is_valid:
189
+ # Mark as invalid, don't process
190
+ results.append({
191
+ "name": seq_item.name,
192
+ "sequence": seq_item.sequence,
193
+ "status": "invalid",
194
+ "validation_error": validation_error,
195
+ "psi": None,
196
+ "interpretation": None,
197
+ "structure": None,
198
+ "mfe": None,
199
+ })
200
+ else:
201
+ # Process valid sequence
202
+ try:
203
+ result = predictor.predict_single(seq_item.sequence)
204
+ force_plot_data = predictor.get_force_plot_data(seq_item.sequence)
205
+ results.append({
206
+ "name": seq_item.name,
207
+ "sequence": seq_item.sequence,
208
+ "status": "success",
209
+ "psi": result["psi"],
210
+ "interpretation": result["interpretation"],
211
+ "structure": result["structure"],
212
+ "mfe": result["mfe"],
213
+ "force_plot_data": force_plot_data,
214
+ "warnings": result.get("warnings"),
215
+ })
216
+ except Exception as e:
217
+ results.append({
218
+ "name": seq_item.name,
219
+ "sequence": seq_item.sequence,
220
+ "status": "error",
221
+ "error": str(e),
222
+ "psi": None,
223
+ "interpretation": None,
224
+ "structure": None,
225
+ "mfe": None,
226
+ })
227
 
228
  # Update job with results
229
  job.status = "finished"
 
236
  db.commit()
237
  raise HTTPException(status_code=500, detail=str(e))
238
 
239
+ # Count results
240
+ successful = sum(1 for r in results if r.get("status") == "success")
241
+ invalid = sum(1 for r in results if r.get("status") == "invalid")
242
+ errored = sum(1 for r in results if r.get("status") == "error")
243
+
244
  return PredictionResponse(
245
  job_id=job_id,
246
  status=job.status,
247
  status_url=f"/api/status/{job_id}",
248
  result_url=f"/result/{job_id}",
249
+ message=f"Batch completed: {successful} successful, {invalid} invalid, {errored} errors",
250
  )
251
 
252
 
 
305
  # Return batch results
306
  results = job.get_batch_results()
307
  successful = sum(1 for r in results if r.get("status") == "success")
308
+ invalid = sum(1 for r in results if r.get("status") == "invalid")
309
+ failed = sum(1 for r in results if r.get("status") == "error")
310
 
311
  return BatchResultResponse(
312
  job_id=job.id,
313
+ job_title=job.job_title,
314
  status=job.status,
315
  total_sequences=len(results),
316
  successful=successful,
317
+ invalid=invalid,
318
  failed=failed,
319
  results=[
320
  BatchResultItem(
321
+ name=r.get("name", f"Seq_{i+1}"),
322
  sequence=r.get("sequence", ""),
323
  status=r.get("status", "unknown"),
324
  psi=r.get("psi"),
325
  interpretation=r.get("interpretation"),
326
  structure=r.get("structure"),
327
  mfe=r.get("mfe"),
328
+ force_plot_data=r.get("force_plot_data"),
329
+ validation_error=r.get("validation_error"),
330
  error=r.get("error"),
331
  warnings=r.get("warnings"),
332
  )
333
+ for i, r in enumerate(results)
334
  ],
335
  created_at=job.created_at,
336
  expires_at=job.expires_at,
 
355
  )
356
 
357
 
358
+ @router.get("/heatmap/{job_id}", tags=["visualization"])
359
+ async def get_heatmap_data(
360
+ job_id: str,
361
+ db: Session = Depends(get_db),
362
+ ):
363
+ """
364
+ Get filter activation heatmap data for a prediction job.
365
+
366
+ Returns position-wise filter activations for heatmap visualization.
367
+ """
368
+ job = db.query(Job).filter(Job.id == job_id).first()
369
+ if not job:
370
+ raise HTTPException(status_code=404, detail="Job not found")
371
+
372
+ if job.status != "finished":
373
+ raise HTTPException(status_code=400, detail="Job not yet complete")
374
+
375
+ # For batch jobs, use the first sequence
376
+ sequence = job.sequence
377
+
378
+ try:
379
+ predictor = get_predictor()
380
+ heatmap_data = predictor.get_heatmap_data(sequence)
381
+ return heatmap_data
382
+ except Exception as e:
383
+ raise HTTPException(status_code=500, detail=f"Error generating heatmap data: {str(e)}")
384
+
385
+
386
  @router.get("/example", response_model=ExampleSequencesResponse, tags=["examples"])
387
  async def get_example_sequences():
388
  """
 
465
 
466
  if job.is_batch:
467
  results = job.get_batch_results()
468
+ header = ["name", "sequence", "psi", "interpretation", "structure", "mfe", "status", "validation_error", "error"]
469
  rows = [delimiter.join(header)]
470
+ for i, r in enumerate(results):
471
  row = [
472
+ r.get("name", f"Seq_{i+1}"),
473
  r.get("sequence", ""),
474
  str(r.get("psi", "")),
475
  r.get("interpretation", ""),
476
  r.get("structure", ""),
477
  str(r.get("mfe", "")),
478
  r.get("status", ""),
479
+ r.get("validation_error", ""),
480
  r.get("error", ""),
481
  ]
482
  rows.append(delimiter.join(row))
 
500
  )
501
 
502
  raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
503
+
504
+
505
+ # ============================================================================
506
+ # History and Job Management Endpoints
507
+ # ============================================================================
508
+
509
+
510
+ @router.get("/history", response_model=JobHistoryResponse, tags=["history"])
511
+ async def get_job_history(
512
+ access_token: str = Query(..., description="User access token"),
513
+ search: Optional[str] = Query(None, description="Search job titles"),
514
+ date_from: Optional[datetime] = Query(None, description="Filter by start date"),
515
+ date_to: Optional[datetime] = Query(None, description="Filter by end date"),
516
+ page: int = Query(1, ge=1, description="Page number"),
517
+ page_size: int = Query(25, ge=1, le=100, description="Results per page"),
518
+ db: Session = Depends(get_db),
519
+ ):
520
+ """
521
+ Get paginated job history for a user token.
522
+
523
+ Jobs are filtered by access_token and optionally by job title search and date range.
524
+ """
525
+ # Build query
526
+ query = db.query(Job).filter(Job.access_token == access_token)
527
+
528
+ # Apply search filter
529
+ if search:
530
+ query = query.filter(Job.job_title.ilike(f"%{search}%"))
531
+
532
+ # Apply date filters
533
+ if date_from:
534
+ query = query.filter(Job.created_at >= date_from)
535
+ if date_to:
536
+ query = query.filter(Job.created_at <= date_to)
537
+
538
+ # Get total count
539
+ total = query.count()
540
+
541
+ # Apply pagination and ordering
542
+ query = query.order_by(Job.created_at.desc())
543
+ query = query.offset((page - 1) * page_size).limit(page_size)
544
+
545
+ jobs = query.all()
546
+
547
+ # Build response
548
+ job_summaries = [
549
+ JobSummary(
550
+ id=job.id,
551
+ job_title=job.job_title,
552
+ created_at=job.created_at,
553
+ status=job.status,
554
+ is_batch=job.is_batch,
555
+ sequence_count=job.get_sequence_count(),
556
+ )
557
+ for job in jobs
558
+ ]
559
+
560
+ total_pages = ceil(total / page_size) if total > 0 else 1
561
+
562
+ return JobHistoryResponse(
563
+ jobs=job_summaries,
564
+ total=total,
565
+ page=page,
566
+ page_size=page_size,
567
+ total_pages=total_pages,
568
+ )
569
+
570
+
571
+ @router.delete("/jobs/{job_id}", tags=["history"])
572
+ async def delete_job(
573
+ job_id: str,
574
+ access_token: str = Query(..., description="User access token"),
575
+ db: Session = Depends(get_db),
576
+ ):
577
+ """
578
+ Delete a job.
579
+
580
+ Only the owner (matching access_token) can delete a job.
581
+ """
582
+ job = db.query(Job).filter(Job.id == job_id).first()
583
+ if not job:
584
+ raise HTTPException(status_code=404, detail="Job not found")
585
+
586
+ if job.access_token != access_token:
587
+ raise HTTPException(status_code=403, detail="Access denied - token does not match")
588
+
589
+ db.delete(job)
590
+ db.commit()
591
+
592
+ return {"status": "deleted", "job_id": job_id}
593
+
594
+
595
+ @router.get("/batch/{job_id}/results", response_model=PaginatedBatchResultsResponse, tags=["results"])
596
+ async def get_batch_results_paginated(
597
+ job_id: str,
598
+ page: int = Query(1, ge=1, description="Page number"),
599
+ page_size: int = Query(25, ge=1, le=100, description="Results per page"),
600
+ search: Optional[str] = Query(None, description="Search by name or sequence"),
601
+ db: Session = Depends(get_db),
602
+ ):
603
+ """
604
+ Get paginated batch results with optional search.
605
+
606
+ Search filters results by sequence name or sequence content.
607
+ """
608
+ job = db.query(Job).filter(Job.id == job_id).first()
609
+ if not job:
610
+ raise HTTPException(status_code=404, detail="Job not found")
611
+
612
+ if not job.is_batch:
613
+ raise HTTPException(status_code=400, detail="This is not a batch job")
614
+
615
+ if job.status != "finished":
616
+ raise HTTPException(status_code=400, detail="Job not yet complete")
617
+
618
+ all_results = job.get_batch_results()
619
+
620
+ # Calculate statistics from all results (before filtering)
621
+ total_sequences = len(all_results)
622
+ successful_count = sum(1 for r in all_results if r.get("status") == "success")
623
+ invalid_count = sum(1 for r in all_results if r.get("status") == "invalid")
624
+ failed_count = sum(1 for r in all_results if r.get("status") == "error")
625
+
626
+ # Calculate average PSI from successful sequences
627
+ successful_psis = [r.get("psi") for r in all_results if r.get("status") == "success" and r.get("psi") is not None]
628
+ average_psi = sum(successful_psis) / len(successful_psis) if successful_psis else None
629
+
630
+ # Add original index to each result for detail lookup
631
+ indexed_results = [(i, r) for i, r in enumerate(all_results)]
632
+
633
+ # Apply search filter
634
+ if search:
635
+ search_lower = search.lower()
636
+ indexed_results = [
637
+ (i, r) for i, r in indexed_results
638
+ if search_lower in r.get("name", "").lower()
639
+ or search_lower in r.get("sequence", "").lower()
640
+ ]
641
+
642
+ # Total after filtering (for pagination)
643
+ total = len(indexed_results)
644
+
645
+ # Apply pagination
646
+ start_idx = (page - 1) * page_size
647
+ end_idx = start_idx + page_size
648
+ paginated_results = indexed_results[start_idx:end_idx]
649
+
650
+ total_pages = ceil(total / page_size) if total > 0 else 1
651
+
652
+ return PaginatedBatchResultsResponse(
653
+ job_id=job.id,
654
+ job_title=job.job_title,
655
+ status=job.status,
656
+ total_sequences=total_sequences,
657
+ successful_count=successful_count,
658
+ invalid_count=invalid_count,
659
+ failed_count=failed_count,
660
+ average_psi=average_psi,
661
+ results=[
662
+ BatchResultItem(
663
+ index=orig_idx,
664
+ name=r.get("name", f"Seq_{orig_idx+1}"),
665
+ sequence=r.get("sequence", ""),
666
+ status=r.get("status", "unknown"),
667
+ psi=r.get("psi"),
668
+ interpretation=r.get("interpretation"),
669
+ structure=r.get("structure"),
670
+ mfe=r.get("mfe"),
671
+ force_plot_data=r.get("force_plot_data"),
672
+ validation_error=r.get("validation_error"),
673
+ error=r.get("error"),
674
+ warnings=r.get("warnings"),
675
+ )
676
+ for orig_idx, r in paginated_results
677
+ ],
678
+ total=total,
679
+ page=page,
680
+ page_size=page_size,
681
+ total_pages=total_pages,
682
+ created_at=job.created_at,
683
+ expires_at=job.expires_at,
684
+ )
685
+
686
+
687
+ @router.get("/batch/{job_id}/sequence/{index}", response_model=SequenceDetailResponse, tags=["results"])
688
+ async def get_sequence_detail(
689
+ job_id: str,
690
+ index: int = Path(..., ge=0, description="Sequence index (0-based)"),
691
+ db: Session = Depends(get_db),
692
+ ):
693
+ """
694
+ Get detailed results for a single sequence in a batch job.
695
+
696
+ Returns full details including force plot data for visualization.
697
+ """
698
+ job = db.query(Job).filter(Job.id == job_id).first()
699
+ if not job:
700
+ raise HTTPException(status_code=404, detail="Job not found")
701
+
702
+ if not job.is_batch:
703
+ raise HTTPException(status_code=400, detail="This is not a batch job")
704
+
705
+ if job.status != "finished":
706
+ raise HTTPException(status_code=400, detail="Job not yet complete")
707
+
708
+ results = job.get_batch_results()
709
+ if index >= len(results):
710
+ raise HTTPException(status_code=404, detail=f"Sequence index {index} not found")
711
+
712
+ r = results[index]
713
+
714
+ return SequenceDetailResponse(
715
+ job_id=job.id,
716
+ index=index,
717
+ name=r.get("name", f"Seq_{index+1}"),
718
+ sequence=r.get("sequence", ""),
719
+ status=r.get("status", "unknown"),
720
+ psi=r.get("psi"),
721
+ interpretation=r.get("interpretation"),
722
+ structure=r.get("structure"),
723
+ mfe=r.get("mfe"),
724
+ force_plot_data=r.get("force_plot_data"),
725
+ validation_error=r.get("validation_error"),
726
+ error=r.get("error"),
727
+ warnings=r.get("warnings"),
728
+ )
webapp/app/api/schemas.py CHANGED
@@ -17,10 +17,25 @@ class SequenceInput(BaseModel):
17
  max_length=settings.exon_length,
18
  description=f"The {settings.exon_length}-nucleotide exon sequence (A, C, G, T only)",
19
  )
 
 
 
 
 
20
  email: Optional[EmailStr] = Field(
21
  None,
22
  description="Optional email address for job completion notification",
23
  )
 
 
 
 
 
 
 
 
 
 
24
 
25
  @field_validator("sequence")
26
  @classmethod
@@ -42,49 +57,71 @@ class SequenceInput(BaseModel):
42
  return v
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  class BatchSequenceInput(BaseModel):
46
  """Schema for batch sequence prediction request."""
47
 
48
- sequences: List[str] = Field(
49
  ...,
50
  min_length=1,
51
  max_length=settings.max_batch_size,
52
- description=f"List of {settings.exon_length}-nucleotide exon sequences",
53
  )
54
  email: Optional[EmailStr] = Field(
55
  None,
56
  description="Optional email address for job completion notification",
57
  )
 
 
 
 
 
 
 
 
 
 
58
 
59
  @field_validator("sequences")
60
  @classmethod
61
- def validate_sequences(cls, v: List[str]) -> List[str]:
62
- """Validate all sequences in the batch."""
63
- validated = []
64
- errors = []
 
 
 
 
65
 
66
- for i, seq in enumerate(v):
67
- seq = seq.upper().replace("U", "T").strip()
68
 
69
- if len(seq) != settings.exon_length:
70
- errors.append(
71
- f"Sequence {i + 1}: must be exactly {settings.exon_length} nucleotides (got {len(seq)})"
72
- )
73
- continue
 
74
 
75
- if not re.match(f"^[ACGT]{{{settings.exon_length}}}$", seq):
76
- invalid_chars = set(seq) - set("ACGT")
77
- errors.append(
78
- f"Sequence {i + 1}: contains invalid characters: {invalid_chars}"
79
- )
80
- continue
81
 
82
- validated.append(seq)
 
 
83
 
84
- if errors:
85
- raise ValueError("; ".join(errors))
86
-
87
- return validated
88
 
89
 
90
  class PredictionResponse(BaseModel):
@@ -127,13 +164,17 @@ class SingleResultResponse(BaseModel):
127
  class BatchResultItem(BaseModel):
128
  """Schema for a single item in batch results."""
129
 
 
 
130
  sequence: str
131
- status: str
132
  psi: Optional[float] = None
133
  interpretation: Optional[str] = None
134
  structure: Optional[str] = None
135
  mfe: Optional[float] = None
136
- error: Optional[str] = None
 
 
137
  warnings: Optional[List[str]] = None
138
 
139
 
@@ -141,9 +182,11 @@ class BatchResultResponse(BaseModel):
141
  """Schema for batch prediction results."""
142
 
143
  job_id: str
 
144
  status: str
145
  total_sequences: int
146
  successful: int
 
147
  failed: int
148
  results: List[BatchResultItem]
149
  created_at: datetime
@@ -179,3 +222,67 @@ class HealthResponse(BaseModel):
179
  version: str
180
  model_loaded: bool
181
  database_connected: bool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  max_length=settings.exon_length,
18
  description=f"The {settings.exon_length}-nucleotide exon sequence (A, C, G, T only)",
19
  )
20
+ name: Optional[str] = Field(
21
+ None,
22
+ max_length=255,
23
+ description="Optional name for the sequence",
24
+ )
25
  email: Optional[EmailStr] = Field(
26
  None,
27
  description="Optional email address for job completion notification",
28
  )
29
+ access_token: Optional[str] = Field(
30
+ None,
31
+ max_length=64,
32
+ description="User access token for job history tracking",
33
+ )
34
+ job_title: Optional[str] = Field(
35
+ None,
36
+ max_length=255,
37
+ description="Optional job title (auto-generated if not provided)",
38
+ )
39
 
40
  @field_validator("sequence")
41
  @classmethod
 
57
  return v
58
 
59
 
60
+ class SequenceItem(BaseModel):
61
+ """Schema for a named sequence in batch requests."""
62
+
63
+ name: str = Field(
64
+ ...,
65
+ max_length=255,
66
+ description="Name/identifier for this sequence",
67
+ )
68
+ sequence: str = Field(
69
+ ...,
70
+ description=f"The {settings.exon_length}-nucleotide exon sequence",
71
+ )
72
+
73
+
74
  class BatchSequenceInput(BaseModel):
75
  """Schema for batch sequence prediction request."""
76
 
77
+ sequences: List[SequenceItem] = Field(
78
  ...,
79
  min_length=1,
80
  max_length=settings.max_batch_size,
81
+ description=f"List of named sequences with {settings.exon_length}-nucleotide exon sequences",
82
  )
83
  email: Optional[EmailStr] = Field(
84
  None,
85
  description="Optional email address for job completion notification",
86
  )
87
+ access_token: Optional[str] = Field(
88
+ None,
89
+ max_length=64,
90
+ description="User access token for job history tracking",
91
+ )
92
+ job_title: Optional[str] = Field(
93
+ None,
94
+ max_length=255,
95
+ description="Optional job title (auto-generated if not provided)",
96
+ )
97
 
98
  @field_validator("sequences")
99
  @classmethod
100
+ def validate_sequences(cls, v: List[SequenceItem]) -> List[SequenceItem]:
101
+ """Normalize sequences (don't reject invalid ones - they'll be marked in results)."""
102
+ normalized = []
103
+ for item in v:
104
+ # Normalize the sequence (uppercase, U->T)
105
+ normalized_seq = item.sequence.upper().replace("U", "T").strip()
106
+ normalized.append(SequenceItem(name=item.name, sequence=normalized_seq))
107
+ return normalized
108
 
 
 
109
 
110
+ def validate_single_sequence(sequence: str) -> tuple[bool, str]:
111
+ """
112
+ Validate a single sequence and return (is_valid, error_message).
113
+ Used by batch processing to mark invalid sequences without rejecting.
114
+ """
115
+ seq = sequence.upper().replace("U", "T").strip()
116
 
117
+ if len(seq) != settings.exon_length:
118
+ return False, f"Must be exactly {settings.exon_length} nucleotides (got {len(seq)})"
 
 
 
 
119
 
120
+ if not re.match(f"^[ACGT]{{{settings.exon_length}}}$", seq):
121
+ invalid_chars = set(seq) - set("ACGT")
122
+ return False, f"Contains invalid characters: {invalid_chars}"
123
 
124
+ return True, ""
 
 
 
125
 
126
 
127
  class PredictionResponse(BaseModel):
 
164
  class BatchResultItem(BaseModel):
165
  """Schema for a single item in batch results."""
166
 
167
+ index: Optional[int] = None # Original index in batch (for detail lookup)
168
+ name: str
169
  sequence: str
170
+ status: str # "success", "invalid", "error"
171
  psi: Optional[float] = None
172
  interpretation: Optional[str] = None
173
  structure: Optional[str] = None
174
  mfe: Optional[float] = None
175
+ force_plot_data: Optional[Dict[str, Any]] = None
176
+ validation_error: Optional[str] = None # For invalid sequences
177
+ error: Optional[str] = None # For processing errors
178
  warnings: Optional[List[str]] = None
179
 
180
 
 
182
  """Schema for batch prediction results."""
183
 
184
  job_id: str
185
+ job_title: Optional[str] = None
186
  status: str
187
  total_sequences: int
188
  successful: int
189
+ invalid: int
190
  failed: int
191
  results: List[BatchResultItem]
192
  created_at: datetime
 
222
  version: str
223
  model_loaded: bool
224
  database_connected: bool
225
+
226
+
227
+ # ============================================================================
228
+ # History and Job Management Schemas
229
+ # ============================================================================
230
+
231
+
232
+ class JobSummary(BaseModel):
233
+ """Schema for job summary in history list."""
234
+
235
+ id: str
236
+ job_title: Optional[str] = None
237
+ created_at: datetime
238
+ status: str
239
+ is_batch: bool
240
+ sequence_count: int
241
+
242
+
243
+ class JobHistoryResponse(BaseModel):
244
+ """Schema for paginated job history response."""
245
+
246
+ jobs: List[JobSummary]
247
+ total: int
248
+ page: int
249
+ page_size: int
250
+ total_pages: int
251
+
252
+
253
+ class PaginatedBatchResultsResponse(BaseModel):
254
+ """Schema for paginated batch results."""
255
+
256
+ job_id: str
257
+ job_title: Optional[str] = None
258
+ status: str
259
+ total_sequences: int
260
+ successful_count: int
261
+ invalid_count: int
262
+ failed_count: int
263
+ average_psi: Optional[float] = None # Average PSI of successful sequences
264
+ results: List[BatchResultItem]
265
+ total: int # Total results after filtering (for pagination)
266
+ page: int
267
+ page_size: int
268
+ total_pages: int
269
+ created_at: datetime
270
+ expires_at: datetime
271
+
272
+
273
+ class SequenceDetailResponse(BaseModel):
274
+ """Schema for detailed single sequence from batch."""
275
+
276
+ job_id: str
277
+ index: int
278
+ name: str
279
+ sequence: str
280
+ status: str
281
+ psi: Optional[float] = None
282
+ interpretation: Optional[str] = None
283
+ structure: Optional[str] = None
284
+ mfe: Optional[float] = None
285
+ force_plot_data: Optional[Dict[str, Any]] = None
286
+ validation_error: Optional[str] = None
287
+ error: Optional[str] = None
288
+ warnings: Optional[List[str]] = None
webapp/app/main.py CHANGED
@@ -10,8 +10,19 @@ from fastapi.middleware.cors import CORSMiddleware
10
  from pathlib import Path
11
 
12
  from webapp.app.config import settings
13
- from webapp.app.database import init_db
14
  from webapp.app.api.routes import router as api_router
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  # Configure logging
17
  logging.basicConfig(
@@ -91,6 +102,15 @@ static_path = Path(__file__).parent.parent / "static"
91
  if static_path.exists():
92
  app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
93
 
 
 
 
 
 
 
 
 
 
94
  # Set up templates
95
  templates_path = Path(__file__).parent.parent / "templates"
96
  templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None
@@ -108,9 +128,18 @@ async def home(request: Request):
108
 
109
  @app.get("/result/{job_id}", response_class=HTMLResponse, include_in_schema=False)
110
  async def result_page(request: Request, job_id: str):
111
- """Render the result page for a job."""
 
 
 
 
 
 
 
 
 
112
  return templates.TemplateResponse(
113
- "result.html",
114
  {"request": request, "job_id": job_id, "settings": settings}
115
  )
116
 
@@ -139,6 +168,12 @@ async def methodology_page(request: Request):
139
  return templates.TemplateResponse("methodology.html", {"request": request, "settings": settings})
140
 
141
 
 
 
 
 
 
 
142
  if __name__ == "__main__":
143
  import uvicorn
144
  uvicorn.run(app, host="0.0.0.0", port=8000)
 
10
  from pathlib import Path
11
 
12
  from webapp.app.config import settings
13
+ from webapp.app.database import init_db, get_db
14
  from webapp.app.api.routes import router as api_router
15
+ from webapp.app.models.job import Job
16
+
17
+ # PyShiny imports for heatmap visualization
18
+ try:
19
+ from shiny import App
20
+ from webapp.app.shiny_apps.heatmap_app import create_app as create_heatmap_app
21
+ SHINY_AVAILABLE = True
22
+ except ImportError:
23
+ SHINY_AVAILABLE = False
24
+ logger = logging.getLogger(__name__)
25
+ logger.warning("PyShiny not available - heatmap visualization will be disabled")
26
 
27
  # Configure logging
28
  logging.basicConfig(
 
102
  if static_path.exists():
103
  app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
104
 
105
+ # Mount PyShiny heatmap app
106
+ if SHINY_AVAILABLE:
107
+ try:
108
+ heatmap_shiny_app = create_heatmap_app(api_base_url="http://localhost:8000")
109
+ app.mount("/shiny/heatmap", heatmap_shiny_app, name="shiny_heatmap")
110
+ logger.info("PyShiny heatmap app mounted at /shiny/heatmap")
111
+ except Exception as e:
112
+ logger.error(f"Failed to mount PyShiny heatmap app: {e}")
113
+
114
  # Set up templates
115
  templates_path = Path(__file__).parent.parent / "templates"
116
  templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None
 
128
 
129
  @app.get("/result/{job_id}", response_class=HTMLResponse, include_in_schema=False)
130
  async def result_page(request: Request, job_id: str):
131
+ """Render the result page for a job. Uses batch_result.html for batch jobs."""
132
+ # Check if job is a batch job
133
+ db = next(get_db())
134
+ try:
135
+ job = db.query(Job).filter(Job.id == job_id).first()
136
+ is_batch = job.is_batch if job else False
137
+ finally:
138
+ db.close()
139
+
140
+ template_name = "batch_result.html" if is_batch else "result.html"
141
  return templates.TemplateResponse(
142
+ template_name,
143
  {"request": request, "job_id": job_id, "settings": settings}
144
  )
145
 
 
168
  return templates.TemplateResponse("methodology.html", {"request": request, "settings": settings})
169
 
170
 
171
+ @app.get("/history", response_class=HTMLResponse, include_in_schema=False)
172
+ async def history_page(request: Request):
173
+ """Render the job history page."""
174
+ return templates.TemplateResponse("history.html", {"request": request, "settings": settings})
175
+
176
+
177
  if __name__ == "__main__":
178
  import uvicorn
179
  uvicorn.run(app, host="0.0.0.0", port=8000)
webapp/app/models/job.py CHANGED
@@ -29,10 +29,14 @@ class Job(Base):
29
  default=lambda: datetime.utcnow() + timedelta(days=settings.job_retention_days),
30
  )
31
 
 
 
 
 
32
  # Input data
33
  sequence = Column(Text, nullable=False)
34
  is_batch = Column(Boolean, default=False)
35
- batch_sequences = Column(Text, nullable=True) # JSON array
36
  email = Column(String(255), nullable=True)
37
 
38
  # Results (nullable until job finishes)
@@ -53,6 +57,8 @@ class Job(Base):
53
  __table_args__ = (
54
  Index("idx_jobs_status", "status"),
55
  Index("idx_jobs_expires", "expires_at"),
 
 
56
  )
57
 
58
  def to_dict(self) -> dict:
@@ -63,6 +69,7 @@ class Job(Base):
63
  "created_at": self.created_at.isoformat() if self.created_at else None,
64
  "updated_at": self.updated_at.isoformat() if self.updated_at else None,
65
  "expires_at": self.expires_at.isoformat() if self.expires_at else None,
 
66
  "sequence": self.sequence,
67
  "is_batch": self.is_batch,
68
  "email": self.email,
@@ -89,14 +96,21 @@ class Job(Base):
89
 
90
  return result
91
 
92
- def set_batch_sequences(self, sequences: List[str]):
93
- """Set batch sequences as JSON."""
94
  self.batch_sequences = json.dumps(sequences)
95
 
96
- def get_batch_sequences(self) -> List[str]:
97
- """Get batch sequences from JSON."""
98
  return json.loads(self.batch_sequences) if self.batch_sequences else []
99
 
 
 
 
 
 
 
 
100
  def set_batch_results(self, results: List[dict]):
101
  """Set batch results as JSON."""
102
  self.batch_results = json.dumps(results)
 
29
  default=lambda: datetime.utcnow() + timedelta(days=settings.job_retention_days),
30
  )
31
 
32
+ # User identification (NAR compliant - no login required)
33
+ access_token = Column(String(64), nullable=True)
34
+ job_title = Column(String(255), nullable=True)
35
+
36
  # Input data
37
  sequence = Column(Text, nullable=False)
38
  is_batch = Column(Boolean, default=False)
39
+ batch_sequences = Column(Text, nullable=True) # JSON array: [{name, sequence}, ...]
40
  email = Column(String(255), nullable=True)
41
 
42
  # Results (nullable until job finishes)
 
57
  __table_args__ = (
58
  Index("idx_jobs_status", "status"),
59
  Index("idx_jobs_expires", "expires_at"),
60
+ Index("idx_jobs_access_token", "access_token"),
61
+ Index("idx_jobs_created_at", "created_at"),
62
  )
63
 
64
  def to_dict(self) -> dict:
 
69
  "created_at": self.created_at.isoformat() if self.created_at else None,
70
  "updated_at": self.updated_at.isoformat() if self.updated_at else None,
71
  "expires_at": self.expires_at.isoformat() if self.expires_at else None,
72
+ "job_title": self.job_title,
73
  "sequence": self.sequence,
74
  "is_batch": self.is_batch,
75
  "email": self.email,
 
96
 
97
  return result
98
 
99
+ def set_batch_sequences(self, sequences: List[dict]):
100
+ """Set batch sequences as JSON. Format: [{name: str, sequence: str}, ...]"""
101
  self.batch_sequences = json.dumps(sequences)
102
 
103
+ def get_batch_sequences(self) -> List[dict]:
104
+ """Get batch sequences from JSON. Returns [{name: str, sequence: str}, ...]"""
105
  return json.loads(self.batch_sequences) if self.batch_sequences else []
106
 
107
+ def get_sequence_count(self) -> int:
108
+ """Get the number of sequences in this job."""
109
+ if not self.is_batch:
110
+ return 1
111
+ sequences = self.get_batch_sequences()
112
+ return len(sequences) if sequences else 1
113
+
114
  def set_batch_results(self, results: List[dict]):
115
  """Set batch results as JSON."""
116
  self.batch_results = json.dumps(results)
webapp/app/services/predictor.py CHANGED
@@ -331,6 +331,78 @@ class SplicingPredictor:
331
  "mfe": mfe,
332
  }
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  @staticmethod
335
  def _get_interpretation(psi: float) -> str:
336
  """Get human-readable interpretation of PSI value."""
 
331
  "mfe": mfe,
332
  }
333
 
334
+ def get_heatmap_data(self, exon_sequence: str) -> Dict[str, Any]:
335
+ """
336
+ Extract filter activations for heatmap visualization.
337
+
338
+ Args:
339
+ exon_sequence: The 70nt exon sequence
340
+
341
+ Returns:
342
+ Dictionary with heatmap data:
343
+ - positions: list of position numbers (1-90)
344
+ - nucleotides: list of nucleotide characters
345
+ - filter_names: list of filter names
346
+ - activations: 2D matrix (filters × positions)
347
+ """
348
+ # Prepare input
349
+ inputs, structure, mfe, _ = self.prepare_input(exon_sequence)
350
+
351
+ # Get full sequence for nucleotide labels
352
+ full_sequence = self.add_flanking(exon_sequence.upper())
353
+
354
+ # Layer configurations: (layer_name, prefix, num_filters, kernel_width)
355
+ layer_configs = [
356
+ ("qc_incl", "incl", 20, 6),
357
+ ("qc_skip", "skip", 20, 6),
358
+ ("c_incl_struct", "incl_struct", 8, 30),
359
+ ("c_skip_struct", "skip_struct", 8, 30),
360
+ ]
361
+
362
+ filter_names = []
363
+ all_activations = []
364
+
365
+ for layer_name, prefix, num_filters, kernel_width in layer_configs:
366
+ try:
367
+ layer = self.model.get_layer(layer_name)
368
+ intermediate_model = tf.keras.Model(
369
+ inputs=self.model.inputs,
370
+ outputs=layer.output,
371
+ )
372
+ # Get activations: shape (1, output_len, num_filters)
373
+ raw_activations = intermediate_model.predict(inputs, verbose=0)[0]
374
+ output_len = raw_activations.shape[0]
375
+
376
+ for i in range(num_filters):
377
+ filter_name = f"{prefix}_{i+1}"
378
+ filter_names.append(filter_name)
379
+
380
+ # Get this filter's activations and apply ReLU
381
+ filter_activations = np.maximum(0, raw_activations[:, i])
382
+
383
+ # Pad to 90 positions if needed
384
+ if output_len < settings.total_length:
385
+ # Center the activations with padding
386
+ pad_left = (settings.total_length - output_len) // 2
387
+ pad_right = settings.total_length - output_len - pad_left
388
+ padded = np.pad(filter_activations, (pad_left, pad_right), mode='constant')
389
+ else:
390
+ # Already correct length
391
+ padded = filter_activations
392
+
393
+ all_activations.append(padded.tolist())
394
+
395
+ except Exception as e:
396
+ logger.warning(f"Could not extract layer {layer_name}: {e}")
397
+
398
+ return {
399
+ "positions": list(range(1, settings.total_length + 1)),
400
+ "nucleotides": list(full_sequence),
401
+ "filter_names": filter_names,
402
+ "activations": all_activations,
403
+ "structure": structure,
404
+ }
405
+
406
  @staticmethod
407
  def _get_interpretation(psi: float) -> str:
408
  """Get human-readable interpretation of PSI value."""
webapp/app/shiny_apps/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """PyShiny applications for visualization."""
webapp/app/shiny_apps/heatmap_app.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyShiny app for Filter × Position heatmap visualization."""
2
+
3
+ from shiny import App, ui, render, reactive
4
+ import plotly.graph_objects as go
5
+ from plotly.subplots import make_subplots
6
+ import httpx
7
+ import numpy as np
8
+ from urllib.parse import parse_qs
9
+
10
+
11
+ def create_app(api_base_url: str = "http://localhost:8000"):
12
+ """Create the PyShiny heatmap app.
13
+
14
+ Args:
15
+ api_base_url: Base URL for the API to fetch heatmap data
16
+ """
17
+
18
+ app_ui = ui.page_fluid(
19
+ ui.head_content(
20
+ ui.tags.style("""
21
+ body {
22
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
23
+ background: #f9fafb;
24
+ margin: 0;
25
+ padding: 16px;
26
+ }
27
+ .filter-panel {
28
+ background: white;
29
+ border-radius: 8px;
30
+ padding: 16px;
31
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
32
+ max-height: 600px;
33
+ overflow-y: auto;
34
+ }
35
+ .filter-section h4 {
36
+ margin: 0 0 8px 0;
37
+ font-size: 14px;
38
+ color: #374151;
39
+ }
40
+ .filter-section {
41
+ margin-bottom: 16px;
42
+ }
43
+ .heatmap-container {
44
+ background: white;
45
+ border-radius: 8px;
46
+ padding: 16px;
47
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
48
+ }
49
+ .error-message {
50
+ color: #dc2626;
51
+ padding: 20px;
52
+ text-align: center;
53
+ }
54
+ .loading {
55
+ padding: 40px;
56
+ text-align: center;
57
+ color: #6b7280;
58
+ }
59
+ """)
60
+ ),
61
+ ui.row(
62
+ ui.column(
63
+ 3,
64
+ ui.div(
65
+ {"class": "filter-panel"},
66
+ ui.h4("Filter × Position heatmap"),
67
+ ui.div(
68
+ {"class": "filter-section"},
69
+ ui.h4("Inclusion Filters", style="color: #22c55e;"),
70
+ ui.output_ui("inclusion_checkboxes"),
71
+ ),
72
+ ui.div(
73
+ {"class": "filter-section"},
74
+ ui.h4("Skipping Filters", style="color: #ef4444;"),
75
+ ui.output_ui("skipping_checkboxes"),
76
+ ),
77
+ ui.div(
78
+ {"class": "filter-section"},
79
+ ui.h4("Structure Filters"),
80
+ ui.output_ui("structure_checkboxes"),
81
+ ),
82
+ ui.hr(),
83
+ ui.input_action_button("select_all", "Select All", class_="btn-sm"),
84
+ ui.input_action_button("deselect_all", "Deselect All", class_="btn-sm"),
85
+ ),
86
+ ),
87
+ ui.column(
88
+ 9,
89
+ ui.div(
90
+ {"class": "heatmap-container"},
91
+ ui.output_ui("heatmap_plot"),
92
+ ),
93
+ ),
94
+ ),
95
+ )
96
+
97
+ def server(input, output, session):
98
+ # Reactive value to store heatmap data
99
+ heatmap_data = reactive.Value(None)
100
+ error_message = reactive.Value(None)
101
+ selected_filters = reactive.Value(set())
102
+
103
+ def get_job_id_from_url():
104
+ """Extract job_id from URL query parameters."""
105
+ try:
106
+ # Access the ASGI scope to get query string
107
+ scope = session.http_conn.scope if hasattr(session, 'http_conn') else None
108
+ if scope and 'query_string' in scope:
109
+ query_string = scope['query_string'].decode('utf-8')
110
+ params = parse_qs(query_string)
111
+ if 'job_id' in params:
112
+ return params['job_id'][0]
113
+ except Exception:
114
+ pass
115
+ return None
116
+
117
+ @reactive.Effect
118
+ def fetch_data():
119
+ """Fetch heatmap data from API on app load."""
120
+ job_id = get_job_id_from_url()
121
+
122
+ if not job_id:
123
+ error_message.set("No job_id provided in URL. Please access this page from a result page.")
124
+ return
125
+
126
+ try:
127
+ # Fetch heatmap data from API
128
+ with httpx.Client(timeout=30.0) as client:
129
+ response = client.get(f"{api_base_url}/api/heatmap/{job_id}")
130
+ response.raise_for_status()
131
+ data = response.json()
132
+ heatmap_data.set(data)
133
+
134
+ # Initialize all filters as selected
135
+ if data and "filter_names" in data:
136
+ selected_filters.set(set(data["filter_names"]))
137
+
138
+ except httpx.HTTPError as e:
139
+ error_message.set(f"Error fetching data: {str(e)}")
140
+ except Exception as e:
141
+ error_message.set(f"Unexpected error: {str(e)}")
142
+
143
+ @output
144
+ @render.ui
145
+ def inclusion_checkboxes():
146
+ data = heatmap_data.get()
147
+ if not data or "filter_names" not in data:
148
+ return ui.p("Loading...")
149
+
150
+ incl_filters = [f for f in data["filter_names"] if f.startswith("incl_") and not f.startswith("incl_struct")]
151
+
152
+ return ui.input_checkbox_group(
153
+ "incl_filters",
154
+ None,
155
+ choices={f: f for f in incl_filters},
156
+ selected=incl_filters,
157
+ )
158
+
159
+ @output
160
+ @render.ui
161
+ def skipping_checkboxes():
162
+ data = heatmap_data.get()
163
+ if not data or "filter_names" not in data:
164
+ return ui.p("Loading...")
165
+
166
+ skip_filters = [f for f in data["filter_names"] if f.startswith("skip_") and not f.startswith("skip_struct")]
167
+
168
+ return ui.input_checkbox_group(
169
+ "skip_filters",
170
+ None,
171
+ choices={f: f for f in skip_filters},
172
+ selected=skip_filters,
173
+ )
174
+
175
+ @output
176
+ @render.ui
177
+ def structure_checkboxes():
178
+ data = heatmap_data.get()
179
+ if not data or "filter_names" not in data:
180
+ return ui.p("Loading...")
181
+
182
+ struct_filters = [f for f in data["filter_names"] if "struct" in f]
183
+
184
+ return ui.input_checkbox_group(
185
+ "struct_filters",
186
+ None,
187
+ choices={f: f for f in struct_filters},
188
+ selected=struct_filters,
189
+ )
190
+
191
+ @reactive.Effect
192
+ @reactive.event(input.select_all)
193
+ def select_all_filters():
194
+ data = heatmap_data.get()
195
+ if data and "filter_names" in data:
196
+ # Update all checkbox groups
197
+ incl_filters = [f for f in data["filter_names"] if f.startswith("incl_") and not f.startswith("incl_struct")]
198
+ skip_filters = [f for f in data["filter_names"] if f.startswith("skip_") and not f.startswith("skip_struct")]
199
+ struct_filters = [f for f in data["filter_names"] if "struct" in f]
200
+
201
+ ui.update_checkbox_group("incl_filters", selected=incl_filters)
202
+ ui.update_checkbox_group("skip_filters", selected=skip_filters)
203
+ ui.update_checkbox_group("struct_filters", selected=struct_filters)
204
+
205
+ @reactive.Effect
206
+ @reactive.event(input.deselect_all)
207
+ def deselect_all_filters():
208
+ ui.update_checkbox_group("incl_filters", selected=[])
209
+ ui.update_checkbox_group("skip_filters", selected=[])
210
+ ui.update_checkbox_group("struct_filters", selected=[])
211
+
212
+ @output
213
+ @render.ui
214
+ def heatmap_plot():
215
+ err = error_message.get()
216
+ if err:
217
+ return ui.div({"class": "error-message"}, err)
218
+
219
+ data = heatmap_data.get()
220
+ if not data:
221
+ return ui.div({"class": "loading"}, "Loading heatmap data...")
222
+
223
+ # Get selected filters from all checkbox groups
224
+ incl_selected = list(input.incl_filters()) if input.incl_filters() else []
225
+ skip_selected = list(input.skip_filters()) if input.skip_filters() else []
226
+ struct_selected = list(input.struct_filters()) if input.struct_filters() else []
227
+
228
+ all_selected = incl_selected + skip_selected + struct_selected
229
+
230
+ if not all_selected:
231
+ return ui.div(
232
+ {"class": "loading"},
233
+ "Select at least one filter to display the heatmap."
234
+ )
235
+
236
+ # Filter data based on selected filters
237
+ filter_names = data["filter_names"]
238
+ activations = data["activations"]
239
+ nucleotides = data["nucleotides"]
240
+ positions = data["positions"]
241
+
242
+ # Get indices of selected filters
243
+ selected_indices = [i for i, name in enumerate(filter_names) if name in all_selected]
244
+
245
+ if not selected_indices:
246
+ return ui.div({"class": "loading"}, "No filters selected.")
247
+
248
+ # Create filtered activation matrix
249
+ filtered_names = [filter_names[i] for i in selected_indices]
250
+ filtered_activations = [activations[i] for i in selected_indices]
251
+
252
+ # Create x-axis labels (nucleotides)
253
+ x_labels = [f"{nucleotides[i]}" for i in range(len(nucleotides))]
254
+
255
+ # Create heatmap
256
+ fig = go.Figure(data=go.Heatmap(
257
+ z=filtered_activations,
258
+ x=x_labels,
259
+ y=filtered_names,
260
+ colorscale="Viridis",
261
+ colorbar=dict(
262
+ title="Strength",
263
+ titleside="right",
264
+ ),
265
+ hovertemplate=(
266
+ "Position: %{x}<br>"
267
+ "Filter: %{y}<br>"
268
+ "Activation: %{z:.3f}<br>"
269
+ "<extra></extra>"
270
+ ),
271
+ ))
272
+
273
+ fig.update_layout(
274
+ title=dict(
275
+ text="Filter Activations by Position",
276
+ x=0.5,
277
+ font=dict(size=16),
278
+ ),
279
+ xaxis=dict(
280
+ title="Sequence Position",
281
+ tickangle=0,
282
+ tickfont=dict(size=8),
283
+ dtick=5, # Show every 5th tick
284
+ ),
285
+ yaxis=dict(
286
+ title="Filter",
287
+ tickfont=dict(size=10),
288
+ ),
289
+ height=max(400, len(filtered_names) * 20 + 100),
290
+ margin=dict(l=100, r=50, t=50, b=100),
291
+ )
292
+
293
+ # Convert to HTML
294
+ html_content = fig.to_html(
295
+ full_html=False,
296
+ include_plotlyjs="cdn",
297
+ config={"responsive": True},
298
+ )
299
+
300
+ return ui.HTML(html_content)
301
+
302
+ return App(app_ui, server)
303
+
304
+
305
+ # Create the app instance
306
+ app = create_app()
webapp/requirements.txt CHANGED
@@ -18,6 +18,7 @@ scipy # Required by figutils
18
  # Visualization
19
  plotly>=5.18.0
20
  kaleido>=0.2.1
 
21
 
22
  # API Documentation
23
  scalar-fastapi>=1.0.0
 
18
  # Visualization
19
  plotly>=5.18.0
20
  kaleido>=0.2.1
21
+ shiny>=0.8.0 # PyShiny for interactive visualizations
22
 
23
  # API Documentation
24
  scalar-fastapi>=1.0.0
webapp/static/js/batch-result.js ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Batch result page JavaScript - Pagination, search, and detail view
3
+ */
4
+
5
+ // State
6
+ let currentPage = 1;
7
+ let pageSize = 25;
8
+ let totalResults = 0;
9
+ let totalPages = 1;
10
+ let searchQuery = '';
11
+ let jobInfo = null;
12
+
13
+ // DOM Elements
14
+ const loadingState = document.getElementById('loading-state');
15
+ const loadingText = document.getElementById('loading-text');
16
+ const resultsContainer = document.getElementById('results-container');
17
+ const errorState = document.getElementById('error-state');
18
+ const errorMessageEl = document.getElementById('error-message');
19
+ const jobTitleEl = document.getElementById('job-title');
20
+ const resultsTableBody = document.getElementById('results-table-body');
21
+ const searchInput = document.getElementById('search-results');
22
+
23
+ // Stats elements
24
+ const statTotal = document.getElementById('stat-total');
25
+ const statSuccess = document.getElementById('stat-success');
26
+ const statInvalid = document.getElementById('stat-invalid');
27
+ const statAvgPsi = document.getElementById('stat-avg-psi');
28
+
29
+ // Pagination elements
30
+ const pageStart = document.getElementById('page-start');
31
+ const pageEnd = document.getElementById('page-end');
32
+ const totalResultsEl = document.getElementById('total-results');
33
+ const pageButtons = document.getElementById('page-buttons');
34
+
35
+ // Export links
36
+ const exportCsv = document.getElementById('export-csv');
37
+ const exportJson = document.getElementById('export-json');
38
+
39
+ // Polling configuration
40
+ const POLL_INTERVAL = 2000; // 2 seconds
41
+ const MAX_POLLS = 120; // 4 minutes max
42
+ let pollCount = 0;
43
+
44
+ /**
45
+ * Initialize the page
46
+ */
47
+ document.addEventListener('DOMContentLoaded', () => {
48
+ if (typeof jobId === 'undefined' || !jobId) {
49
+ showError('No job ID provided');
50
+ return;
51
+ }
52
+
53
+ // Set export links
54
+ exportCsv.href = `/api/export/${jobId}/csv`;
55
+ exportJson.href = `/api/export/${jobId}/json`;
56
+
57
+ // Event listeners
58
+ if (searchInput) {
59
+ let searchTimeout;
60
+ searchInput.addEventListener('input', (e) => {
61
+ clearTimeout(searchTimeout);
62
+ searchTimeout = setTimeout(() => {
63
+ searchQuery = e.target.value.trim();
64
+ currentPage = 1;
65
+ loadResults();
66
+ }, 300);
67
+ });
68
+ }
69
+
70
+ // Start loading
71
+ fetchJobInfo();
72
+ });
73
+
74
+ /**
75
+ * Fetch job information first
76
+ */
77
+ async function fetchJobInfo() {
78
+ try {
79
+ const response = await fetch(`/api/result/${jobId}`);
80
+
81
+ if (response.status === 404) {
82
+ showError('Job not found. It may have expired.');
83
+ return;
84
+ }
85
+
86
+ if (!response.ok) {
87
+ throw new Error('Failed to fetch job info');
88
+ }
89
+
90
+ const data = await response.json();
91
+ jobInfo = data;
92
+
93
+ // Check status
94
+ if (data.status === 'finished' || data.status === 'completed') {
95
+ // Job is done, load results
96
+ updateJobHeader();
97
+ loadResults();
98
+ } else if (data.status === 'failed') {
99
+ showError(data.error || 'Batch processing failed');
100
+ } else if (data.status === 'running' || data.status === 'queued') {
101
+ // Still processing
102
+ loadingText.textContent = `Processing batch... (${pollCount * 2}s)`;
103
+ pollCount++;
104
+ if (pollCount < MAX_POLLS) {
105
+ setTimeout(fetchJobInfo, POLL_INTERVAL);
106
+ } else {
107
+ showError('Request timed out. Please try again later.');
108
+ }
109
+ } else {
110
+ // Try to show results anyway
111
+ updateJobHeader();
112
+ loadResults();
113
+ }
114
+
115
+ } catch (error) {
116
+ console.error('Error fetching job info:', error);
117
+ showError('Failed to load job information');
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Update job header with title
123
+ */
124
+ function updateJobHeader() {
125
+ if (jobInfo && jobInfo.job_title) {
126
+ jobTitleEl.textContent = jobInfo.job_title;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Load paginated results
132
+ */
133
+ async function loadResults() {
134
+ try {
135
+ const params = new URLSearchParams({
136
+ page: currentPage,
137
+ page_size: pageSize
138
+ });
139
+
140
+ if (searchQuery) {
141
+ params.append('search', searchQuery);
142
+ }
143
+
144
+ const response = await fetch(`/api/batch/${jobId}/results?${params.toString()}`);
145
+
146
+ if (!response.ok) {
147
+ const error = await response.json();
148
+ throw new Error(error.detail || 'Failed to load results');
149
+ }
150
+
151
+ const data = await response.json();
152
+
153
+ // Update stats
154
+ updateStats(data);
155
+
156
+ // Update table
157
+ totalResults = data.total;
158
+ totalPages = data.total_pages;
159
+ renderResults(data.results);
160
+ renderPagination();
161
+
162
+ // Show results container
163
+ loadingState.classList.add('hidden');
164
+ resultsContainer.classList.remove('hidden');
165
+
166
+ } catch (error) {
167
+ console.error('Error loading results:', error);
168
+ showError(error.message);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Update stats display
174
+ */
175
+ function updateStats(data) {
176
+ statTotal.textContent = data.total_sequences || 0;
177
+ statSuccess.textContent = data.successful_count || 0;
178
+ statInvalid.textContent = data.invalid_count || 0;
179
+
180
+ if (data.average_psi !== null && data.average_psi !== undefined) {
181
+ statAvgPsi.textContent = data.average_psi.toFixed(3);
182
+ } else {
183
+ statAvgPsi.textContent = '-';
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Render results table
189
+ */
190
+ function renderResults(results) {
191
+ resultsTableBody.innerHTML = '';
192
+
193
+ for (const result of results) {
194
+ const row = document.createElement('tr');
195
+ row.className = 'hover:bg-gray-50 cursor-pointer';
196
+ row.onclick = () => showDetail(result.index);
197
+
198
+ const statusBadge = getStatusBadge(result.status);
199
+ const psiDisplay = result.status === 'success' && result.psi !== null
200
+ ? result.psi.toFixed(3)
201
+ : '-';
202
+ const psiClass = result.status === 'success' ? getPsiColorClass(result.psi) : 'text-gray-400';
203
+
204
+ // Truncate sequence
205
+ const truncatedSeq = result.sequence.length > 30
206
+ ? result.sequence.substring(0, 30) + '...'
207
+ : result.sequence;
208
+
209
+ row.innerHTML = `
210
+ <td class="px-4 py-3 text-gray-400">
211
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
212
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
213
+ </svg>
214
+ </td>
215
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
216
+ ${escapeHtml(result.name)}
217
+ </td>
218
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-500">
219
+ ${escapeHtml(truncatedSeq)}
220
+ </td>
221
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-bold ${psiClass}">
222
+ ${psiDisplay}
223
+ </td>
224
+ <td class="px-4 py-3 whitespace-nowrap">
225
+ ${statusBadge}
226
+ </td>
227
+ `;
228
+
229
+ resultsTableBody.appendChild(row);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Get status badge HTML
235
+ */
236
+ function getStatusBadge(status) {
237
+ if (status === 'success') {
238
+ return '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Success</span>';
239
+ } else if (status === 'invalid') {
240
+ return '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Invalid</span>';
241
+ } else {
242
+ return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">${status}</span>`;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Get PSI color class
248
+ */
249
+ function getPsiColorClass(psi) {
250
+ if (psi >= 0.8) return 'text-green-600';
251
+ if (psi >= 0.3) return 'text-yellow-600';
252
+ return 'text-red-600';
253
+ }
254
+
255
+ /**
256
+ * Render pagination controls
257
+ */
258
+ function renderPagination() {
259
+ const start = (currentPage - 1) * pageSize + 1;
260
+ const end = Math.min(currentPage * pageSize, totalResults);
261
+
262
+ pageStart.textContent = totalResults > 0 ? start : 0;
263
+ pageEnd.textContent = end;
264
+ totalResultsEl.textContent = totalResults;
265
+
266
+ // Clear existing buttons
267
+ pageButtons.innerHTML = '';
268
+
269
+ if (totalPages <= 1) return;
270
+
271
+ // Previous button
272
+ const prevBtn = document.createElement('button');
273
+ prevBtn.className = `relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${currentPage === 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
274
+ prevBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>';
275
+ prevBtn.disabled = currentPage === 1;
276
+ prevBtn.addEventListener('click', () => {
277
+ if (currentPage > 1) {
278
+ currentPage--;
279
+ loadResults();
280
+ }
281
+ });
282
+ pageButtons.appendChild(prevBtn);
283
+
284
+ // Page numbers
285
+ const maxButtons = 5;
286
+ let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
287
+ let endPage = Math.min(totalPages, startPage + maxButtons - 1);
288
+
289
+ if (endPage - startPage + 1 < maxButtons) {
290
+ startPage = Math.max(1, endPage - maxButtons + 1);
291
+ }
292
+
293
+ for (let i = startPage; i <= endPage; i++) {
294
+ const btn = document.createElement('button');
295
+ btn.className = `relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium ${i === currentPage ? 'bg-primary-50 border-primary-500 text-primary-600 z-10' : 'bg-white text-gray-500 hover:bg-gray-50'}`;
296
+ btn.textContent = i;
297
+ btn.addEventListener('click', () => {
298
+ currentPage = i;
299
+ loadResults();
300
+ });
301
+ pageButtons.appendChild(btn);
302
+ }
303
+
304
+ // Next button
305
+ const nextBtn = document.createElement('button');
306
+ nextBtn.className = `relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${currentPage === totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
307
+ nextBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>';
308
+ nextBtn.disabled = currentPage === totalPages;
309
+ nextBtn.addEventListener('click', () => {
310
+ if (currentPage < totalPages) {
311
+ currentPage++;
312
+ loadResults();
313
+ }
314
+ });
315
+ pageButtons.appendChild(nextBtn);
316
+ }
317
+
318
+ /**
319
+ * Show sequence detail modal
320
+ */
321
+ async function showDetail(index) {
322
+ const modal = document.getElementById('detail-modal');
323
+ const detailTitle = document.getElementById('detail-title');
324
+ const detailContent = document.getElementById('detail-content');
325
+ const detailLoading = document.getElementById('detail-loading');
326
+
327
+ // Show modal with loading
328
+ modal.classList.remove('hidden');
329
+ detailLoading.classList.remove('hidden');
330
+ detailContent.innerHTML = '';
331
+ detailContent.appendChild(detailLoading);
332
+
333
+ try {
334
+ const response = await fetch(`/api/batch/${jobId}/sequence/${index}`);
335
+
336
+ if (!response.ok) {
337
+ const error = await response.json();
338
+ throw new Error(error.detail || 'Failed to load sequence details');
339
+ }
340
+
341
+ const data = await response.json();
342
+ detailLoading.classList.add('hidden');
343
+
344
+ // Update title
345
+ detailTitle.textContent = data.name || `Sequence ${index + 1}`;
346
+
347
+ // Build detail content
348
+ let html = '';
349
+
350
+ if (data.status === 'invalid') {
351
+ // Show invalid sequence info
352
+ html = `
353
+ <div class="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
354
+ <svg class="h-12 w-12 text-red-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
355
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
356
+ </svg>
357
+ <h4 class="mt-4 text-lg font-medium text-red-800">Invalid Sequence</h4>
358
+ <p class="mt-2 text-red-700">${escapeHtml(data.validation_error || 'Sequence validation failed')}</p>
359
+ </div>
360
+ <div class="bg-gray-50 rounded-lg p-4">
361
+ <h4 class="text-sm font-medium text-gray-500 uppercase mb-2">Sequence</h4>
362
+ <p class="font-mono text-sm text-gray-900 break-all">${escapeHtml(data.sequence)}</p>
363
+ </div>
364
+ `;
365
+ } else {
366
+ // Show successful prediction details
367
+ const psi = data.psi;
368
+ const interp = interpretPsi(psi);
369
+
370
+ html = `
371
+ <!-- PSI Value -->
372
+ <div class="rounded-lg p-6 text-center ${interp.colorClass}">
373
+ <p class="text-sm font-medium text-gray-500 uppercase tracking-wide">Predicted PSI</p>
374
+ <p class="mt-2 text-4xl font-bold ${interp.textClass}">${psi.toFixed(3)}</p>
375
+ <p class="mt-2 text-sm ${interp.textClass}">${interp.text}: ${interp.description}</p>
376
+ </div>
377
+
378
+ <!-- Info Grid -->
379
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
380
+ <div class="bg-gray-50 rounded-lg p-4">
381
+ <h4 class="text-sm font-medium text-gray-500 uppercase mb-2">RNA Secondary Structure</h4>
382
+ <p class="font-mono text-sm text-gray-900 break-all">${escapeHtml(data.structure || 'N/A')}</p>
383
+ </div>
384
+ <div class="bg-gray-50 rounded-lg p-4">
385
+ <h4 class="text-sm font-medium text-gray-500 uppercase mb-2">Minimum Free Energy</h4>
386
+ <p class="text-2xl font-bold text-gray-900">${data.mfe ? data.mfe.toFixed(2) : 'N/A'} <span class="text-base font-normal text-gray-500">kcal/mol</span></p>
387
+ </div>
388
+ </div>
389
+
390
+ <!-- Sequence -->
391
+ <div class="bg-gray-50 rounded-lg p-4">
392
+ <h4 class="text-sm font-medium text-gray-500 uppercase mb-2">Sequence</h4>
393
+ <p class="font-mono text-sm text-gray-900 break-all">${escapeHtml(data.sequence)}</p>
394
+ </div>
395
+
396
+ <!-- Force Plot -->
397
+ <div class="bg-white rounded-lg border border-gray-200 p-4">
398
+ <h4 class="text-sm font-medium text-gray-500 uppercase mb-4">
399
+ Position-wise Contribution to PSI
400
+ <span class="ml-2 text-gray-400 font-normal normal-case">
401
+ (green = promotes inclusion, red = promotes skipping)
402
+ </span>
403
+ </h4>
404
+ <div id="detail-force-plot" class="w-full" style="height: 350px;"></div>
405
+ </div>
406
+ `;
407
+ }
408
+
409
+ detailContent.innerHTML = html;
410
+
411
+ // Create force plot if we have data
412
+ if (data.status === 'success' && data.force_plot_data && data.force_plot_data.length > 0) {
413
+ setTimeout(() => createForcePlot('detail-force-plot', data.force_plot_data), 100);
414
+ }
415
+
416
+ } catch (error) {
417
+ console.error('Error loading detail:', error);
418
+ detailLoading.classList.add('hidden');
419
+ detailContent.innerHTML = `
420
+ <div class="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
421
+ <p class="text-red-700">${escapeHtml(error.message)}</p>
422
+ </div>
423
+ `;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Close detail modal
429
+ */
430
+ function closeDetailModal() {
431
+ document.getElementById('detail-modal').classList.add('hidden');
432
+ }
433
+
434
+ // Close modal on escape key
435
+ document.addEventListener('keydown', (e) => {
436
+ if (e.key === 'Escape') {
437
+ closeDetailModal();
438
+ }
439
+ });
440
+
441
+ // Close modal on backdrop click
442
+ document.getElementById('detail-modal').addEventListener('click', (e) => {
443
+ if (e.target === e.currentTarget) {
444
+ closeDetailModal();
445
+ }
446
+ });
447
+
448
+ /**
449
+ * Interpret PSI value
450
+ */
451
+ function interpretPsi(psi) {
452
+ if (psi >= 0.8) {
453
+ return {
454
+ text: 'High Inclusion',
455
+ description: 'This exon is predicted to be included in most transcripts.',
456
+ colorClass: 'bg-green-50 border border-green-200',
457
+ textClass: 'text-green-600'
458
+ };
459
+ } else if (psi >= 0.3) {
460
+ return {
461
+ text: 'Variable/Regulated',
462
+ description: 'This exon shows intermediate inclusion, suggesting regulation.',
463
+ colorClass: 'bg-yellow-50 border border-yellow-200',
464
+ textClass: 'text-yellow-600'
465
+ };
466
+ } else {
467
+ return {
468
+ text: 'High Skipping',
469
+ description: 'This exon is predicted to be skipped in most transcripts.',
470
+ colorClass: 'bg-red-50 border border-red-200',
471
+ textClass: 'text-red-600'
472
+ };
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Create force plot using Plotly
478
+ */
479
+ function createForcePlot(elementId, forceData) {
480
+ const element = document.getElementById(elementId);
481
+ if (!element) return;
482
+
483
+ const positions = Array.from({ length: forceData.length }, (_, i) => i + 1);
484
+ const colors = forceData.map(v => v >= 0 ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)');
485
+
486
+ const trace = {
487
+ x: positions,
488
+ y: forceData,
489
+ type: 'bar',
490
+ marker: { color: colors },
491
+ hovertemplate: 'Position %{x}<br>Contribution: %{y:.4f}<extra></extra>'
492
+ };
493
+
494
+ const layout = {
495
+ margin: { t: 20, r: 20, b: 50, l: 50 },
496
+ xaxis: {
497
+ title: { text: 'Position', font: { size: 11 } },
498
+ tickmode: 'linear',
499
+ dtick: 10,
500
+ range: [0, 91]
501
+ },
502
+ yaxis: {
503
+ title: { text: 'Contribution to PSI', font: { size: 11 } },
504
+ zeroline: true,
505
+ zerolinecolor: '#888',
506
+ zerolinewidth: 1
507
+ },
508
+ shapes: [{
509
+ type: 'rect',
510
+ xref: 'x',
511
+ yref: 'paper',
512
+ x0: 10.5,
513
+ x1: 80.5,
514
+ y0: 0,
515
+ y1: 1,
516
+ fillcolor: 'rgba(59, 130, 246, 0.05)',
517
+ line: { width: 0 }
518
+ }],
519
+ annotations: [
520
+ { x: 5, y: 1.05, xref: 'x', yref: 'paper', text: "5' flank", showarrow: false, font: { size: 9, color: '#888' } },
521
+ { x: 45, y: 1.05, xref: 'x', yref: 'paper', text: 'Exon', showarrow: false, font: { size: 9, color: '#3b82f6' } },
522
+ { x: 85, y: 1.05, xref: 'x', yref: 'paper', text: "3' flank", showarrow: false, font: { size: 9, color: '#888' } }
523
+ ],
524
+ paper_bgcolor: 'rgba(0,0,0,0)',
525
+ plot_bgcolor: 'rgba(0,0,0,0)',
526
+ font: { family: 'system-ui, -apple-system, sans-serif' }
527
+ };
528
+
529
+ const config = {
530
+ responsive: true,
531
+ displayModeBar: true,
532
+ modeBarButtonsToRemove: ['lasso2d', 'select2d'],
533
+ displaylogo: false
534
+ };
535
+
536
+ Plotly.newPlot(element, [trace], layout, config);
537
+ }
538
+
539
+ /**
540
+ * Show error state
541
+ */
542
+ function showError(message) {
543
+ loadingState.classList.add('hidden');
544
+ resultsContainer.classList.add('hidden');
545
+ errorState.classList.remove('hidden');
546
+ errorMessageEl.textContent = message;
547
+ }
548
+
549
+ /**
550
+ * Copy result link to clipboard
551
+ */
552
+ function copyLink() {
553
+ const url = window.location.href;
554
+ navigator.clipboard.writeText(url).then(() => {
555
+ alert('Link copied to clipboard!');
556
+ }).catch(err => {
557
+ console.error('Failed to copy:', err);
558
+ prompt('Copy this link:', url);
559
+ });
560
+ }
561
+
562
+ /**
563
+ * Escape HTML to prevent XSS
564
+ */
565
+ function escapeHtml(text) {
566
+ const div = document.createElement('div');
567
+ div.textContent = text;
568
+ return div.innerHTML;
569
+ }
570
+
571
+ // Make functions available globally
572
+ window.copyLink = copyLink;
573
+ window.closeDetailModal = closeDetailModal;
574
+ window.showDetail = showDetail;
webapp/static/js/file-parser.js ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * File parsing utilities for CSV and FASTA files.
3
+ * Also handles multi-sequence text input parsing.
4
+ */
5
+
6
+ /**
7
+ * Parse FASTA format text.
8
+ * Format: >Name\nSEQUENCE\n>Name2\nSEQUENCE2
9
+ * @param {string} text - FASTA formatted text
10
+ * @returns {Array<{name: string, sequence: string}>}
11
+ */
12
+ function parseFasta(text) {
13
+ const sequences = [];
14
+ const lines = text.trim().split('\n');
15
+ let currentName = '';
16
+ let currentSeq = '';
17
+ let seqCount = 0;
18
+
19
+ for (const line of lines) {
20
+ const trimmedLine = line.trim();
21
+ if (trimmedLine.startsWith('>')) {
22
+ // Save previous sequence if exists
23
+ if (currentSeq) {
24
+ sequences.push({
25
+ name: currentName || `Seq_${seqCount}`,
26
+ sequence: currentSeq.toUpperCase()
27
+ });
28
+ }
29
+ seqCount++;
30
+ // Extract name from header (remove > and trim)
31
+ currentName = trimmedLine.substring(1).trim() || `Seq_${seqCount}`;
32
+ currentSeq = '';
33
+ } else if (trimmedLine) {
34
+ // Append to current sequence (remove whitespace)
35
+ currentSeq += trimmedLine.replace(/\s/g, '');
36
+ }
37
+ }
38
+
39
+ // Don't forget the last sequence
40
+ if (currentSeq) {
41
+ sequences.push({
42
+ name: currentName || `Seq_${seqCount}`,
43
+ sequence: currentSeq.toUpperCase()
44
+ });
45
+ }
46
+
47
+ return sequences;
48
+ }
49
+
50
+ /**
51
+ * Parse plain sequences (one per line, no headers).
52
+ * @param {string} text - Plain text with one sequence per line
53
+ * @returns {Array<{name: string, sequence: string}>}
54
+ */
55
+ function parsePlainSequences(text) {
56
+ const lines = text.trim().split('\n');
57
+ const sequences = [];
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const seq = lines[i].trim().toUpperCase().replace(/\s/g, '');
61
+ if (seq.length > 0) {
62
+ sequences.push({
63
+ name: `Seq_${sequences.length + 1}`,
64
+ sequence: seq
65
+ });
66
+ }
67
+ }
68
+
69
+ return sequences;
70
+ }
71
+
72
+ /**
73
+ * Auto-detect format and parse multi-sequence text.
74
+ * If any line starts with '>', treat as FASTA, otherwise plain sequences.
75
+ * @param {string} text - Text to parse
76
+ * @returns {Array<{name: string, sequence: string}>}
77
+ */
78
+ function parseMultiSequenceText(text) {
79
+ const trimmed = text.trim();
80
+
81
+ if (!trimmed) {
82
+ return [];
83
+ }
84
+
85
+ // Check if it looks like FASTA (any line starts with >)
86
+ if (trimmed.includes('>')) {
87
+ return parseFasta(trimmed);
88
+ }
89
+
90
+ // Otherwise treat as plain sequences
91
+ return parsePlainSequences(trimmed);
92
+ }
93
+
94
+ /**
95
+ * Detect the delimiter used in a CSV file.
96
+ * @param {string} text - CSV text content
97
+ * @returns {string} Detected delimiter: ',', ';', or '\t'
98
+ */
99
+ function detectDelimiter(text) {
100
+ const firstLine = text.split('\n')[0] || '';
101
+
102
+ // Count occurrences of each potential delimiter in first line
103
+ const tabCount = (firstLine.match(/\t/g) || []).length;
104
+ const semicolonCount = (firstLine.match(/;/g) || []).length;
105
+ const commaCount = (firstLine.match(/,/g) || []).length;
106
+
107
+ // Return the most common delimiter
108
+ if (tabCount > 0 && tabCount >= semicolonCount && tabCount >= commaCount) {
109
+ return '\t';
110
+ }
111
+ if (semicolonCount > 0 && semicolonCount >= commaCount) {
112
+ return ';';
113
+ }
114
+ return ',';
115
+ }
116
+
117
+ /**
118
+ * Detect if the first row is a header row.
119
+ * @param {Array<string>} row - First row of CSV
120
+ * @returns {boolean} True if row looks like a header
121
+ */
122
+ function detectHeader(row) {
123
+ if (!row || row.length === 0) return false;
124
+
125
+ // Common header patterns (case insensitive)
126
+ const headerPatterns = ['name', 'sequence', 'seq', 'id', 'exon', 'header'];
127
+
128
+ // Check if any cell matches header patterns
129
+ for (const cell of row) {
130
+ const lowerCell = cell.toLowerCase().trim();
131
+ if (headerPatterns.some(pattern => lowerCell.includes(pattern))) {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ // If first cell looks like a valid sequence (all ACGTU), probably not a header
137
+ const firstCell = row[0].toUpperCase().trim();
138
+ if (/^[ACGTU]+$/.test(firstCell) && firstCell.length > 20) {
139
+ return false;
140
+ }
141
+
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Parse CSV content.
147
+ * Supports:
148
+ * - 2 columns: name, sequence (with or without header)
149
+ * - 1 column: sequence only (with or without header)
150
+ * - Auto-detects delimiter (comma, semicolon, tab)
151
+ * @param {string} text - CSV file content
152
+ * @returns {Array<{name: string, sequence: string}>}
153
+ */
154
+ function parseCSV(text) {
155
+ const delimiter = detectDelimiter(text);
156
+ const lines = text.trim().split('\n');
157
+
158
+ if (lines.length === 0) {
159
+ return [];
160
+ }
161
+
162
+ // Parse all rows
163
+ const rows = lines.map(line => {
164
+ // Handle quoted fields properly
165
+ const cells = [];
166
+ let current = '';
167
+ let inQuotes = false;
168
+
169
+ for (let i = 0; i < line.length; i++) {
170
+ const char = line[i];
171
+ if (char === '"') {
172
+ inQuotes = !inQuotes;
173
+ } else if (char === delimiter && !inQuotes) {
174
+ cells.push(current.trim());
175
+ current = '';
176
+ } else {
177
+ current += char;
178
+ }
179
+ }
180
+ cells.push(current.trim());
181
+ return cells;
182
+ });
183
+
184
+ // Detect if first row is header
185
+ const hasHeader = detectHeader(rows[0]);
186
+ const dataRows = hasHeader ? rows.slice(1) : rows;
187
+
188
+ // Determine format based on number of columns
189
+ const numCols = rows[0].length;
190
+
191
+ const sequences = [];
192
+ for (let i = 0; i < dataRows.length; i++) {
193
+ const row = dataRows[i];
194
+ if (!row || row.length === 0 || (row.length === 1 && !row[0].trim())) {
195
+ continue; // Skip empty rows
196
+ }
197
+
198
+ if (numCols >= 2 && row.length >= 2) {
199
+ // Two or more columns: assume name, sequence
200
+ const name = row[0].trim() || `Seq_${sequences.length + 1}`;
201
+ const seq = row[1].trim().toUpperCase().replace(/\s/g, '');
202
+ if (seq) {
203
+ sequences.push({ name, sequence: seq });
204
+ }
205
+ } else {
206
+ // Single column: sequence only
207
+ const seq = row[0].trim().toUpperCase().replace(/\s/g, '');
208
+ if (seq) {
209
+ sequences.push({
210
+ name: `Seq_${sequences.length + 1}`,
211
+ sequence: seq
212
+ });
213
+ }
214
+ }
215
+ }
216
+
217
+ return sequences;
218
+ }
219
+
220
+ /**
221
+ * Read and parse a file (CSV or FASTA).
222
+ * @param {File} file - File object to parse
223
+ * @returns {Promise<Array<{name: string, sequence: string}>>}
224
+ */
225
+ async function parseFile(file) {
226
+ return new Promise((resolve, reject) => {
227
+ const reader = new FileReader();
228
+
229
+ reader.onload = (event) => {
230
+ const text = event.target.result;
231
+ const fileName = file.name.toLowerCase();
232
+
233
+ let sequences;
234
+ if (fileName.endsWith('.fasta') || fileName.endsWith('.fa') || fileName.endsWith('.fna')) {
235
+ sequences = parseFasta(text);
236
+ } else if (fileName.endsWith('.csv') || fileName.endsWith('.tsv') || fileName.endsWith('.txt')) {
237
+ // Try CSV first
238
+ sequences = parseCSV(text);
239
+ // If CSV parsing resulted in weird sequences, try FASTA
240
+ if (sequences.length === 0 || (sequences.length === 1 && text.includes('>'))) {
241
+ sequences = parseFasta(text);
242
+ }
243
+ } else {
244
+ // Unknown extension - try to auto-detect
245
+ if (text.trim().startsWith('>')) {
246
+ sequences = parseFasta(text);
247
+ } else {
248
+ sequences = parseCSV(text);
249
+ }
250
+ }
251
+
252
+ resolve(sequences);
253
+ };
254
+
255
+ reader.onerror = () => {
256
+ reject(new Error('Failed to read file'));
257
+ };
258
+
259
+ reader.readAsText(file);
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Validate a single sequence.
265
+ * @param {string} sequence - Sequence to validate
266
+ * @param {number} expectedLength - Expected length (default 70)
267
+ * @returns {{valid: boolean, error: string}}
268
+ */
269
+ function validateSequence(sequence, expectedLength = 70) {
270
+ const cleaned = sequence.toUpperCase().replace(/U/g, 'T').replace(/\s/g, '');
271
+
272
+ if (cleaned.length !== expectedLength) {
273
+ return {
274
+ valid: false,
275
+ error: `Must be exactly ${expectedLength} nucleotides (got ${cleaned.length})`
276
+ };
277
+ }
278
+
279
+ const invalidChars = cleaned.match(/[^ACGT]/g);
280
+ if (invalidChars) {
281
+ const unique = [...new Set(invalidChars)];
282
+ return {
283
+ valid: false,
284
+ error: `Contains invalid characters: ${unique.join(', ')}`
285
+ };
286
+ }
287
+
288
+ return { valid: true, error: '' };
289
+ }
290
+
291
+ /**
292
+ * Parse and validate sequences from text input or file.
293
+ * @param {string} text - Text input to parse
294
+ * @param {number} expectedLength - Expected sequence length
295
+ * @returns {{sequences: Array<{name: string, sequence: string}>, validCount: number, invalidCount: number}}
296
+ */
297
+ function parseAndValidate(text, expectedLength = 70) {
298
+ const sequences = parseMultiSequenceText(text);
299
+ let validCount = 0;
300
+ let invalidCount = 0;
301
+
302
+ for (const seq of sequences) {
303
+ const validation = validateSequence(seq.sequence, expectedLength);
304
+ seq.valid = validation.valid;
305
+ seq.error = validation.error;
306
+ if (validation.valid) {
307
+ validCount++;
308
+ } else {
309
+ invalidCount++;
310
+ }
311
+ }
312
+
313
+ return { sequences, validCount, invalidCount };
314
+ }
315
+
316
+ // Export functions for use in other scripts
317
+ window.FileParser = {
318
+ parseFasta,
319
+ parsePlainSequences,
320
+ parseMultiSequenceText,
321
+ parseCSV,
322
+ parseFile,
323
+ detectDelimiter,
324
+ detectHeader,
325
+ validateSequence,
326
+ parseAndValidate,
327
+ };
webapp/static/js/history.js ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * History page JavaScript
3
+ */
4
+
5
+ // State
6
+ let currentPage = 1;
7
+ let pageSize = 25;
8
+ let totalJobs = 0;
9
+ let totalPages = 1;
10
+ let currentFilters = {
11
+ search: '',
12
+ dateFrom: null,
13
+ dateTo: null
14
+ };
15
+
16
+ // DOM Elements
17
+ const tokenDisplay = document.getElementById('token-display');
18
+ const loadingState = document.getElementById('loading-state');
19
+ const noJobsState = document.getElementById('no-jobs-state');
20
+ const jobsTableContainer = document.getElementById('jobs-table-container');
21
+ const jobsTableBody = document.getElementById('jobs-table-body');
22
+ const pagination = document.getElementById('pagination');
23
+ const pageStart = document.getElementById('page-start');
24
+ const pageEnd = document.getElementById('page-end');
25
+ const totalJobsEl = document.getElementById('total-jobs');
26
+ const pageButtons = document.getElementById('page-buttons');
27
+ const searchInput = document.getElementById('search-title');
28
+ const dateFromInput = document.getElementById('date-from');
29
+ const dateToInput = document.getElementById('date-to');
30
+ const applyFiltersBtn = document.getElementById('apply-filters-btn');
31
+ const clearFiltersBtn = document.getElementById('clear-filters-btn');
32
+ const refreshBtn = document.getElementById('refresh-btn');
33
+
34
+ /**
35
+ * Initialize the page
36
+ */
37
+ document.addEventListener('DOMContentLoaded', () => {
38
+ TokenManager.initTokenDisplay();
39
+ loadJobs();
40
+
41
+ // Event listeners
42
+ if (applyFiltersBtn) {
43
+ applyFiltersBtn.addEventListener('click', applyFilters);
44
+ }
45
+
46
+ if (clearFiltersBtn) {
47
+ clearFiltersBtn.addEventListener('click', clearFilters);
48
+ }
49
+
50
+ if (refreshBtn) {
51
+ refreshBtn.addEventListener('click', () => loadJobs());
52
+ }
53
+
54
+ // Search on Enter
55
+ if (searchInput) {
56
+ searchInput.addEventListener('keypress', (e) => {
57
+ if (e.key === 'Enter') {
58
+ applyFilters();
59
+ }
60
+ });
61
+ }
62
+
63
+ // Setup custom token save handler for history page
64
+ const tokenSaveBtn = document.getElementById('token-save-btn');
65
+ if (tokenSaveBtn) {
66
+ tokenSaveBtn.addEventListener('click', () => {
67
+ const editInput = document.getElementById('token-edit-input');
68
+ const newToken = editInput.value.trim();
69
+ if (TokenManager.setToken(newToken)) {
70
+ if (tokenDisplay) {
71
+ tokenDisplay.textContent = newToken;
72
+ }
73
+ document.getElementById('token-edit-modal').classList.add('hidden');
74
+ loadJobs(); // Reload jobs with new token
75
+ } else {
76
+ alert('Invalid token format. Token must be in format: tok_xxxxxxxxxxxx');
77
+ }
78
+ });
79
+ }
80
+ });
81
+
82
+ /**
83
+ * Apply current filters and load jobs
84
+ */
85
+ function applyFilters() {
86
+ currentFilters.search = searchInput.value.trim();
87
+ currentFilters.dateFrom = dateFromInput.value || null;
88
+ currentFilters.dateTo = dateToInput.value || null;
89
+ currentPage = 1;
90
+ loadJobs();
91
+ }
92
+
93
+ /**
94
+ * Clear all filters
95
+ */
96
+ function clearFilters() {
97
+ searchInput.value = '';
98
+ dateFromInput.value = '';
99
+ dateToInput.value = '';
100
+ currentFilters = {
101
+ search: '',
102
+ dateFrom: null,
103
+ dateTo: null
104
+ };
105
+ currentPage = 1;
106
+ loadJobs();
107
+ }
108
+
109
+ /**
110
+ * Load jobs from API
111
+ */
112
+ async function loadJobs() {
113
+ const token = TokenManager.getOrCreateToken();
114
+ if (!token) {
115
+ showNoJobs();
116
+ return;
117
+ }
118
+
119
+ showLoading();
120
+
121
+ try {
122
+ // Build query parameters
123
+ const params = new URLSearchParams({
124
+ access_token: token,
125
+ page: currentPage,
126
+ page_size: pageSize
127
+ });
128
+
129
+ if (currentFilters.search) {
130
+ params.append('search', currentFilters.search);
131
+ }
132
+ if (currentFilters.dateFrom) {
133
+ params.append('date_from', currentFilters.dateFrom);
134
+ }
135
+ if (currentFilters.dateTo) {
136
+ params.append('date_to', currentFilters.dateTo);
137
+ }
138
+
139
+ const response = await fetch(`/api/history?${params.toString()}`);
140
+
141
+ if (!response.ok) {
142
+ const error = await response.json();
143
+ throw new Error(error.detail || 'Failed to load jobs');
144
+ }
145
+
146
+ const data = await response.json();
147
+ totalJobs = data.total;
148
+ totalPages = data.total_pages;
149
+
150
+ if (data.jobs.length === 0) {
151
+ showNoJobs();
152
+ } else {
153
+ renderJobs(data.jobs);
154
+ renderPagination();
155
+ showTable();
156
+ }
157
+
158
+ } catch (error) {
159
+ console.error('Error loading jobs:', error);
160
+ showNoJobs();
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Render jobs in the table
166
+ */
167
+ function renderJobs(jobs) {
168
+ jobsTableBody.innerHTML = '';
169
+
170
+ for (const job of jobs) {
171
+ const row = document.createElement('tr');
172
+ row.className = 'hover:bg-gray-50';
173
+
174
+ const statusBadge = getStatusBadge(job.status);
175
+ const date = new Date(job.created_at).toLocaleDateString('en-US', {
176
+ year: 'numeric',
177
+ month: 'short',
178
+ day: 'numeric',
179
+ hour: '2-digit',
180
+ minute: '2-digit'
181
+ });
182
+
183
+ row.innerHTML = `
184
+ <td class="px-6 py-4 whitespace-nowrap">
185
+ <a href="/result/${job.id}" class="text-primary-600 hover:text-primary-800 font-medium">
186
+ ${escapeHtml(job.job_title || job.id.substring(0, 8))}
187
+ </a>
188
+ </td>
189
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
190
+ <td class="px-6 py-4 whitespace-nowrap">${statusBadge}</td>
191
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
192
+ ${job.sequence_count} ${job.is_batch ? 'sequences' : 'sequence'}
193
+ </td>
194
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
195
+ <a href="/result/${job.id}" class="text-primary-600 hover:text-primary-800 mr-3">View</a>
196
+ <button onclick="deleteJob('${job.id}')" class="text-red-600 hover:text-red-800">Delete</button>
197
+ </td>
198
+ `;
199
+
200
+ jobsTableBody.appendChild(row);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Get status badge HTML
206
+ */
207
+ function getStatusBadge(status) {
208
+ const badges = {
209
+ 'finished': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Completed</span>',
210
+ 'running': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Running</span>',
211
+ 'queued': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Queued</span>',
212
+ 'failed': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Failed</span>'
213
+ };
214
+ return badges[status] || `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">${status}</span>`;
215
+ }
216
+
217
+ /**
218
+ * Render pagination controls
219
+ */
220
+ function renderPagination() {
221
+ const start = (currentPage - 1) * pageSize + 1;
222
+ const end = Math.min(currentPage * pageSize, totalJobs);
223
+
224
+ pageStart.textContent = start;
225
+ pageEnd.textContent = end;
226
+ totalJobsEl.textContent = totalJobs;
227
+
228
+ // Clear existing buttons
229
+ pageButtons.innerHTML = '';
230
+
231
+ // Previous button
232
+ const prevBtn = document.createElement('button');
233
+ prevBtn.className = `relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${currentPage === 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
234
+ prevBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>';
235
+ prevBtn.disabled = currentPage === 1;
236
+ prevBtn.addEventListener('click', () => {
237
+ if (currentPage > 1) {
238
+ currentPage--;
239
+ loadJobs();
240
+ }
241
+ });
242
+ pageButtons.appendChild(prevBtn);
243
+
244
+ // Page numbers
245
+ const maxButtons = 5;
246
+ let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
247
+ let endPage = Math.min(totalPages, startPage + maxButtons - 1);
248
+
249
+ if (endPage - startPage + 1 < maxButtons) {
250
+ startPage = Math.max(1, endPage - maxButtons + 1);
251
+ }
252
+
253
+ for (let i = startPage; i <= endPage; i++) {
254
+ const btn = document.createElement('button');
255
+ btn.className = `relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium ${i === currentPage ? 'bg-primary-50 border-primary-500 text-primary-600 z-10' : 'bg-white text-gray-500 hover:bg-gray-50'}`;
256
+ btn.textContent = i;
257
+ btn.addEventListener('click', () => {
258
+ currentPage = i;
259
+ loadJobs();
260
+ });
261
+ pageButtons.appendChild(btn);
262
+ }
263
+
264
+ // Next button
265
+ const nextBtn = document.createElement('button');
266
+ nextBtn.className = `relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${currentPage === totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
267
+ nextBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>';
268
+ nextBtn.disabled = currentPage === totalPages;
269
+ nextBtn.addEventListener('click', () => {
270
+ if (currentPage < totalPages) {
271
+ currentPage++;
272
+ loadJobs();
273
+ }
274
+ });
275
+ pageButtons.appendChild(nextBtn);
276
+ }
277
+
278
+ /**
279
+ * Delete a job
280
+ */
281
+ async function deleteJob(jobId) {
282
+ if (!confirm('Are you sure you want to delete this job? This action cannot be undone.')) {
283
+ return;
284
+ }
285
+
286
+ const token = TokenManager.getToken();
287
+ if (!token) {
288
+ alert('No access token found');
289
+ return;
290
+ }
291
+
292
+ try {
293
+ const response = await fetch(`/api/jobs/${jobId}?access_token=${encodeURIComponent(token)}`, {
294
+ method: 'DELETE'
295
+ });
296
+
297
+ if (!response.ok) {
298
+ const error = await response.json();
299
+ throw new Error(error.detail || 'Failed to delete job');
300
+ }
301
+
302
+ // Reload jobs
303
+ loadJobs();
304
+
305
+ } catch (error) {
306
+ console.error('Error deleting job:', error);
307
+ alert('Failed to delete job: ' + error.message);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Show loading state
313
+ */
314
+ function showLoading() {
315
+ loadingState.classList.remove('hidden');
316
+ noJobsState.classList.add('hidden');
317
+ jobsTableContainer.classList.add('hidden');
318
+ pagination.classList.add('hidden');
319
+ }
320
+
321
+ /**
322
+ * Show no jobs state
323
+ */
324
+ function showNoJobs() {
325
+ loadingState.classList.add('hidden');
326
+ noJobsState.classList.remove('hidden');
327
+ jobsTableContainer.classList.add('hidden');
328
+ pagination.classList.add('hidden');
329
+ }
330
+
331
+ /**
332
+ * Show table
333
+ */
334
+ function showTable() {
335
+ loadingState.classList.add('hidden');
336
+ noJobsState.classList.add('hidden');
337
+ jobsTableContainer.classList.remove('hidden');
338
+ pagination.classList.remove('hidden');
339
+ }
340
+
341
+ /**
342
+ * Escape HTML to prevent XSS
343
+ */
344
+ function escapeHtml(text) {
345
+ const div = document.createElement('div');
346
+ div.textContent = text;
347
+ return div.innerHTML;
348
+ }
349
+
350
+ // Make deleteJob available globally
351
+ window.deleteJob = deleteJob;
webapp/static/js/token.js ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Token management utilities for user identification.
3
+ * Tokens are stored in localStorage and used to track job history.
4
+ * Format: tok_xxxxxxxxxxxx (12 random alphanumeric characters)
5
+ */
6
+
7
+ const TOKEN_KEY = 'splicing_access_token';
8
+ const TOKEN_PREFIX = 'tok_';
9
+ const TOKEN_RANDOM_LENGTH = 12;
10
+
11
+ /**
12
+ * Generate a random token.
13
+ * @returns {string} Token in format tok_xxxxxxxxxxxx
14
+ */
15
+ function generateToken() {
16
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
17
+ let result = TOKEN_PREFIX;
18
+ for (let i = 0; i < TOKEN_RANDOM_LENGTH; i++) {
19
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
20
+ }
21
+ return result;
22
+ }
23
+
24
+ /**
25
+ * Validate token format.
26
+ * @param {string} token - Token to validate
27
+ * @returns {boolean} True if valid format
28
+ */
29
+ function isValidToken(token) {
30
+ const pattern = new RegExp(`^${TOKEN_PREFIX}[a-z0-9]{${TOKEN_RANDOM_LENGTH}}$`);
31
+ return pattern.test(token);
32
+ }
33
+
34
+ /**
35
+ * Get current token from localStorage, or generate new one.
36
+ * @returns {string} The access token
37
+ */
38
+ function getOrCreateToken() {
39
+ let token = localStorage.getItem(TOKEN_KEY);
40
+ if (!token || !isValidToken(token)) {
41
+ token = generateToken();
42
+ localStorage.setItem(TOKEN_KEY, token);
43
+ }
44
+ return token;
45
+ }
46
+
47
+ /**
48
+ * Get current token from localStorage (may be null).
49
+ * @returns {string|null} The access token or null
50
+ */
51
+ function getToken() {
52
+ return localStorage.getItem(TOKEN_KEY);
53
+ }
54
+
55
+ /**
56
+ * Set a new token in localStorage.
57
+ * @param {string} newToken - The new token to set
58
+ * @returns {boolean} True if successfully set
59
+ */
60
+ function setToken(newToken) {
61
+ if (!isValidToken(newToken)) {
62
+ return false;
63
+ }
64
+ localStorage.setItem(TOKEN_KEY, newToken);
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Clear the token from localStorage.
70
+ */
71
+ function clearToken() {
72
+ localStorage.removeItem(TOKEN_KEY);
73
+ }
74
+
75
+ /**
76
+ * Copy token to clipboard.
77
+ * @returns {Promise<boolean>} True if successfully copied
78
+ */
79
+ async function copyTokenToClipboard() {
80
+ const token = getToken();
81
+ if (!token) return false;
82
+
83
+ try {
84
+ await navigator.clipboard.writeText(token);
85
+ return true;
86
+ } catch (err) {
87
+ console.error('Failed to copy token:', err);
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Initialize token display in the UI.
94
+ * Call this on page load to show current token.
95
+ */
96
+ function initTokenDisplay() {
97
+ const token = getOrCreateToken();
98
+
99
+ // Update token display element if it exists
100
+ const displayEl = document.getElementById('token-display');
101
+ if (displayEl) {
102
+ displayEl.textContent = token;
103
+ displayEl.title = 'Double-click to edit';
104
+ displayEl.style.cursor = 'pointer';
105
+
106
+ // Double-click to edit inline
107
+ displayEl.addEventListener('dblclick', () => {
108
+ startInlineEdit(displayEl);
109
+ });
110
+ }
111
+
112
+ // Setup copy button
113
+ const copyBtn = document.getElementById('token-copy-btn');
114
+ if (copyBtn) {
115
+ copyBtn.addEventListener('click', async () => {
116
+ const success = await copyTokenToClipboard();
117
+ if (success) {
118
+ const originalText = copyBtn.textContent;
119
+ copyBtn.textContent = 'Copied!';
120
+ setTimeout(() => {
121
+ copyBtn.textContent = originalText;
122
+ }, 2000);
123
+ }
124
+ });
125
+ }
126
+
127
+ return token;
128
+ }
129
+
130
+ /**
131
+ * Start inline editing of the token display.
132
+ * @param {HTMLElement} displayEl - The token display element
133
+ */
134
+ function startInlineEdit(displayEl) {
135
+ const currentToken = displayEl.textContent;
136
+ const originalBg = displayEl.style.backgroundColor;
137
+
138
+ // Make editable
139
+ displayEl.contentEditable = true;
140
+ displayEl.style.backgroundColor = '#fff';
141
+ displayEl.style.outline = '2px solid #3b82f6';
142
+ displayEl.style.outlineOffset = '1px';
143
+ displayEl.focus();
144
+
145
+ // Select all text
146
+ const range = document.createRange();
147
+ range.selectNodeContents(displayEl);
148
+ const selection = window.getSelection();
149
+ selection.removeAllRanges();
150
+ selection.addRange(range);
151
+
152
+ // Handle blur (save on click away)
153
+ const handleBlur = () => {
154
+ finishInlineEdit(displayEl, currentToken, originalBg);
155
+ };
156
+
157
+ // Handle keydown (Enter to save, Escape to cancel)
158
+ const handleKeydown = (e) => {
159
+ if (e.key === 'Enter') {
160
+ e.preventDefault();
161
+ displayEl.blur();
162
+ } else if (e.key === 'Escape') {
163
+ displayEl.textContent = currentToken;
164
+ displayEl.blur();
165
+ }
166
+ };
167
+
168
+ displayEl.addEventListener('blur', handleBlur, { once: true });
169
+ displayEl.addEventListener('keydown', handleKeydown);
170
+
171
+ // Remove keydown listener after blur
172
+ displayEl.addEventListener('blur', () => {
173
+ displayEl.removeEventListener('keydown', handleKeydown);
174
+ }, { once: true });
175
+ }
176
+
177
+ /**
178
+ * Finish inline editing and save if valid.
179
+ * @param {HTMLElement} displayEl - The token display element
180
+ * @param {string} originalToken - The original token before editing
181
+ * @param {string} originalBg - The original background color
182
+ */
183
+ function finishInlineEdit(displayEl, originalToken, originalBg) {
184
+ displayEl.contentEditable = false;
185
+ displayEl.style.backgroundColor = originalBg;
186
+ displayEl.style.outline = '';
187
+ displayEl.style.outlineOffset = '';
188
+
189
+ const newToken = displayEl.textContent.trim();
190
+
191
+ if (newToken === originalToken) {
192
+ // No change
193
+ return;
194
+ }
195
+
196
+ if (setToken(newToken)) {
197
+ displayEl.textContent = newToken;
198
+ } else {
199
+ // Invalid token, revert
200
+ displayEl.textContent = originalToken;
201
+ alert('Invalid token format. Token must be: tok_ followed by 12 lowercase letters/numbers');
202
+ }
203
+ }
204
+
205
+ // Export functions for use in other scripts
206
+ window.TokenManager = {
207
+ generateToken,
208
+ isValidToken,
209
+ getOrCreateToken,
210
+ getToken,
211
+ setToken,
212
+ clearToken,
213
+ copyTokenToClipboard,
214
+ initTokenDisplay,
215
+ TOKEN_KEY,
216
+ };
webapp/templates/about.html CHANGED
@@ -198,31 +198,6 @@
198
  </div>
199
  </section>
200
 
201
- <!-- Citation Section -->
202
- <section class="mb-12">
203
- <h2 class="text-2xl font-bold text-gray-900 mb-4">Citation</h2>
204
- <div class="bg-gray-50 rounded-lg border border-gray-200 p-6">
205
- <p class="text-gray-700 mb-4">If you use this tool in your research, please cite:</p>
206
- <blockquote class="border-l-4 border-primary-500 pl-4 italic text-gray-600">
207
- Liao SE, Sudarshan M, and Regev O. "Machine learning for discovery: deciphering RNA splicing logic." <em>bioRxiv</em> (2022).
208
- </blockquote>
209
- <div class="mt-4 flex space-x-4">
210
- <a href="https://www.biorxiv.org/content/10.1101/2022.10.01.510472v1" target="_blank" class="inline-flex items-center text-primary-600 hover:text-primary-700">
211
- <svg class="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
212
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z"/>
213
- </svg>
214
- View Paper
215
- </a>
216
- <a href="https://github.com/Sachin1801/interpretable-splicing-model" target="_blank" class="inline-flex items-center text-primary-600 hover:text-primary-700">
217
- <svg class="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
218
- <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
219
- </svg>
220
- View Code
221
- </a>
222
- </div>
223
- </div>
224
- </section>
225
-
226
  <!-- CTA -->
227
  <div class="text-center">
228
  <a href="/" class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
 
198
  </div>
199
  </section>
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  <!-- CTA -->
202
  <div class="text-center">
203
  <a href="/" class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
webapp/templates/base.html CHANGED
@@ -72,8 +72,8 @@
72
  <a href="/tutorial" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md {% if request.url.path == '/tutorial' %}text-primary-600 bg-primary-50{% endif %}">
73
  Tutorial
74
  </a>
75
- <a href="/docs" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">
76
- API Docs
77
  </a>
78
  </div>
79
 
@@ -99,23 +99,11 @@
99
  <a href="/methodology" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Methodology</a>
100
  <a href="/help" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Help</a>
101
  <a href="/tutorial" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Tutorial</a>
102
- <a href="/docs" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">API Docs</a>
103
  </div>
104
  </div>
105
  </nav>
106
 
107
- <!-- Free access banner -->
108
- <div class="bg-green-50 border-b border-green-200">
109
- <div class="max-w-7xl mx-auto py-2 px-4 sm:px-6 lg:px-8">
110
- <p class="text-sm text-center text-green-800">
111
- <svg class="inline h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
112
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
113
- </svg>
114
- This website is <strong>free and open</strong> to all users. No login required.
115
- </p>
116
- </div>
117
- </div>
118
-
119
  <!-- Main content -->
120
  <main class="flex-grow">
121
  {% block content %}{% endblock %}
@@ -124,34 +112,18 @@
124
  <!-- Footer -->
125
  <footer class="bg-white border-t border-gray-200 mt-auto">
126
  <div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
127
- <div class="md:flex md:items-center md:justify-between">
128
- <div class="flex justify-center space-x-6 md:order-2">
129
- <a href="https://www.biorxiv.org/content/10.1101/2022.10.01.510472v1" target="_blank" class="text-gray-400 hover:text-gray-500">
130
- <span class="sr-only">Paper</span>
131
- <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
132
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z"/>
133
- </svg>
134
- </a>
135
- <a href="https://github.com/Sachin1801/interpretable-splicing-model" target="_blank" class="text-gray-400 hover:text-gray-500">
136
- <span class="sr-only">GitHub</span>
137
- <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
138
- <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
139
- </svg>
140
- </a>
141
- </div>
142
- <div class="mt-8 md:mt-0 md:order-1">
143
- <p class="text-center text-sm text-gray-500">
144
- <strong>Citation:</strong> Liao SE, Sudarshan M, and Regev O.
145
- "Machine learning for discovery: deciphering RNA splicing logic."
146
- <em>bioRxiv</em> (2022).
147
- </p>
148
- </div>
149
  </div>
150
- <div class="mt-4 border-t border-gray-200 pt-4">
151
  <p class="text-center text-xs text-gray-400">
152
  Version {{ settings.app_version }} |
153
- <a href="/help" class="hover:text-gray-500">Help</a> |
154
- <a href="/docs" class="hover:text-gray-500">API Documentation</a>
155
  </p>
156
  </div>
157
  </div>
 
72
  <a href="/tutorial" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md {% if request.url.path == '/tutorial' %}text-primary-600 bg-primary-50{% endif %}">
73
  Tutorial
74
  </a>
75
+ <a href="/history" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md {% if request.url.path == '/history' %}text-primary-600 bg-primary-50{% endif %}">
76
+ History
77
  </a>
78
  </div>
79
 
 
99
  <a href="/methodology" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Methodology</a>
100
  <a href="/help" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Help</a>
101
  <a href="/tutorial" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Tutorial</a>
102
+ <a href="/history" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">History</a>
103
  </div>
104
  </div>
105
  </nav>
106
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  <!-- Main content -->
108
  <main class="flex-grow">
109
  {% block content %}{% endblock %}
 
112
  <!-- Footer -->
113
  <footer class="bg-white border-t border-gray-200 mt-auto">
114
  <div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
115
+ <div class="flex justify-center">
116
+ <a href="https://github.com/Sachin1801/interpretable-splicing-model" target="_blank" class="text-gray-400 hover:text-gray-500">
117
+ <span class="sr-only">GitHub</span>
118
+ <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
119
+ <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
120
+ </svg>
121
+ </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  </div>
123
+ <div class="mt-4">
124
  <p class="text-center text-xs text-gray-400">
125
  Version {{ settings.app_version }} |
126
+ <a href="/help" class="hover:text-gray-500">Help</a>
 
127
  </p>
128
  </div>
129
  </div>
webapp/templates/batch_result.html ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Batch Results - {{ settings.app_name }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
7
+ <!-- Header -->
8
+ <div class="mb-6">
9
+ <nav class="flex" aria-label="Breadcrumb">
10
+ <ol class="flex items-center space-x-2">
11
+ <li>
12
+ <a href="/" class="text-gray-400 hover:text-gray-500">
13
+ <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
14
+ <path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
15
+ </svg>
16
+ </a>
17
+ </li>
18
+ <li>
19
+ <svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
20
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
21
+ </svg>
22
+ </li>
23
+ <li>
24
+ <a href="/history" class="text-sm font-medium text-gray-500 hover:text-gray-700">History</a>
25
+ </li>
26
+ <li>
27
+ <svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
28
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
29
+ </svg>
30
+ </li>
31
+ <li>
32
+ <span class="text-sm font-medium text-gray-500">Batch Results</span>
33
+ </li>
34
+ </ol>
35
+ </nav>
36
+ <h1 class="mt-2 text-2xl font-bold text-gray-900" id="job-title">Batch Results</h1>
37
+ <p class="text-sm text-gray-500">Job ID: <code class="bg-gray-100 px-2 py-1 rounded text-xs">{{ job_id }}</code></p>
38
+ </div>
39
+
40
+ <!-- Loading State -->
41
+ <div id="loading-state" class="text-center py-12">
42
+ <svg class="animate-spin h-10 w-10 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
43
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
44
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
45
+ </svg>
46
+ <p class="mt-4 text-gray-500" id="loading-text">Loading results...</p>
47
+ </div>
48
+
49
+ <!-- Results Container (hidden initially) -->
50
+ <div id="results-container" class="hidden space-y-6">
51
+ <!-- Summary Stats -->
52
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
53
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
54
+ <p class="text-sm font-medium text-gray-500">Total Sequences</p>
55
+ <p id="stat-total" class="mt-1 text-2xl font-bold text-gray-900">0</p>
56
+ </div>
57
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
58
+ <p class="text-sm font-medium text-gray-500">Successful</p>
59
+ <p id="stat-success" class="mt-1 text-2xl font-bold text-green-600">0</p>
60
+ </div>
61
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
62
+ <p class="text-sm font-medium text-gray-500">Invalid</p>
63
+ <p id="stat-invalid" class="mt-1 text-2xl font-bold text-red-600">0</p>
64
+ </div>
65
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
66
+ <p class="text-sm font-medium text-gray-500">Average PSI</p>
67
+ <p id="stat-avg-psi" class="mt-1 text-2xl font-bold text-primary-600">-</p>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Search and Export -->
72
+ <div class="flex flex-wrap gap-4 items-center justify-between">
73
+ <div class="flex-1 min-w-[200px] max-w-md">
74
+ <input type="text" id="search-results"
75
+ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm"
76
+ placeholder="Search by name or sequence...">
77
+ </div>
78
+ <div class="flex gap-2">
79
+ <a id="export-csv" href="#" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
80
+ <svg class="mr-1.5 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
81
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
82
+ </svg>
83
+ CSV
84
+ </a>
85
+ <a id="export-json" href="#" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
86
+ <svg class="mr-1.5 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
87
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
88
+ </svg>
89
+ JSON
90
+ </a>
91
+ <button onclick="copyLink()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
92
+ <svg class="mr-1.5 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
94
+ </svg>
95
+ Copy Link
96
+ </button>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Results Table -->
101
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
102
+ <table class="min-w-full divide-y divide-gray-200">
103
+ <thead class="bg-gray-50">
104
+ <tr>
105
+ <th scope="col" class="w-10 px-4 py-3"></th>
106
+ <th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
107
+ <th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sequence</th>
108
+ <th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">PSI</th>
109
+ <th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
110
+ </tr>
111
+ </thead>
112
+ <tbody id="results-table-body" class="bg-white divide-y divide-gray-200">
113
+ <!-- Populated by JavaScript -->
114
+ </tbody>
115
+ </table>
116
+ </div>
117
+
118
+ <!-- Pagination -->
119
+ <div id="pagination" class="flex items-center justify-between">
120
+ <div>
121
+ <p class="text-sm text-gray-700">
122
+ Showing <span id="page-start">1</span> to <span id="page-end">25</span> of <span id="total-results">0</span> results
123
+ </p>
124
+ </div>
125
+ <div>
126
+ <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination" id="page-buttons">
127
+ <!-- Populated by JavaScript -->
128
+ </nav>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- Error State -->
134
+ <div id="error-state" class="hidden">
135
+ <div class="rounded-lg bg-red-50 border border-red-200 p-6 text-center">
136
+ <svg class="h-12 w-12 text-red-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
137
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
138
+ </svg>
139
+ <h3 class="mt-4 text-lg font-medium text-red-800">Error Loading Results</h3>
140
+ <p id="error-message" class="mt-2 text-red-700"></p>
141
+ <a href="/" class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200">
142
+ Try Again
143
+ </a>
144
+ </div>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Detail Modal -->
149
+ <div id="detail-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
150
+ <div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
151
+ <div class="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
152
+ <h3 id="detail-title" class="text-lg font-medium text-gray-900">Sequence Details</h3>
153
+ <button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-500">
154
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
155
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
156
+ </svg>
157
+ </button>
158
+ </div>
159
+ <div id="detail-content" class="p-6 space-y-6">
160
+ <!-- Loading spinner for detail -->
161
+ <div id="detail-loading" class="text-center py-8">
162
+ <svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
163
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
164
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
165
+ </svg>
166
+ </div>
167
+ <!-- Detail will be populated here -->
168
+ </div>
169
+ </div>
170
+ </div>
171
+ {% endblock %}
172
+
173
+ {% block scripts %}
174
+ <script>
175
+ const jobId = "{{ job_id }}";
176
+ </script>
177
+ <script src="/static/js/batch-result.js"></script>
178
+ {% endblock %}
webapp/templates/history.html ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Job History - {{ settings.app_name }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-6xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
7
+ <div class="mb-6">
8
+ <h1 class="text-2xl font-bold text-gray-900">Job History</h1>
9
+ <p class="mt-2 text-sm text-gray-600">
10
+ View and manage your previous prediction jobs. Jobs are identified by your access token.
11
+ </p>
12
+ </div>
13
+
14
+ <!-- Token Entry Section -->
15
+ <div id="token-section" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
16
+ <div class="flex items-center justify-between flex-wrap gap-4">
17
+ <div class="flex items-center gap-2 flex-wrap">
18
+ <span class="text-sm font-medium text-blue-900">Access Token:</span>
19
+ <code id="token-display" class="bg-white px-3 py-1 rounded border border-blue-200 text-sm font-mono text-blue-800"></code>
20
+ <button type="button" id="token-copy-btn" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
21
+ Copy
22
+ </button>
23
+ <button type="button" id="token-edit-btn" class="text-gray-600 hover:text-gray-800 text-sm font-medium">
24
+ Edit
25
+ </button>
26
+ </div>
27
+ <button type="button" id="refresh-btn" class="inline-flex items-center px-3 py-1.5 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
28
+ <svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
29
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
30
+ </svg>
31
+ Refresh
32
+ </button>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- Token Edit Modal -->
37
+ <div id="token-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
38
+ <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
39
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Edit Access Token</h3>
40
+ <p class="text-sm text-gray-600 mb-4">
41
+ Enter a different token to view jobs from another device or session.
42
+ </p>
43
+ <input type="text" id="token-edit-input" class="w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono text-sm mb-4" placeholder="tok_xxxxxxxxxxxx">
44
+ <div class="flex justify-end gap-3">
45
+ <button type="button" id="token-cancel-btn" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">Cancel</button>
46
+ <button type="button" id="token-save-btn" class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">Load Jobs</button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- Filters -->
52
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
53
+ <div class="flex flex-wrap gap-4 items-end">
54
+ <div class="flex-1 min-w-[200px]">
55
+ <label for="search-title" class="block text-sm font-medium text-gray-700 mb-1">Search Job Titles</label>
56
+ <input type="text" id="search-title"
57
+ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm"
58
+ placeholder="Search...">
59
+ </div>
60
+ <div>
61
+ <label for="date-from" class="block text-sm font-medium text-gray-700 mb-1">From Date</label>
62
+ <input type="date" id="date-from"
63
+ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm">
64
+ </div>
65
+ <div>
66
+ <label for="date-to" class="block text-sm font-medium text-gray-700 mb-1">To Date</label>
67
+ <input type="date" id="date-to"
68
+ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm">
69
+ </div>
70
+ <button type="button" id="apply-filters-btn"
71
+ class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">
72
+ Apply
73
+ </button>
74
+ <button type="button" id="clear-filters-btn"
75
+ class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200">
76
+ Clear
77
+ </button>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Loading State -->
82
+ <div id="loading-state" class="hidden text-center py-8">
83
+ <svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
84
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
85
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
86
+ </svg>
87
+ <p class="mt-2 text-sm text-gray-500">Loading jobs...</p>
88
+ </div>
89
+
90
+ <!-- No Jobs State -->
91
+ <div id="no-jobs-state" class="hidden text-center py-12">
92
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
94
+ </svg>
95
+ <h3 class="mt-2 text-sm font-medium text-gray-900">No jobs found</h3>
96
+ <p class="mt-1 text-sm text-gray-500">No prediction jobs found for this token.</p>
97
+ <div class="mt-6">
98
+ <a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
99
+ Create New Prediction
100
+ </a>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- Jobs Table -->
105
+ <div id="jobs-table-container" class="hidden bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
106
+ <table class="min-w-full divide-y divide-gray-200">
107
+ <thead class="bg-gray-50">
108
+ <tr>
109
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Title</th>
110
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
111
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
112
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sequences</th>
113
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
114
+ </tr>
115
+ </thead>
116
+ <tbody id="jobs-table-body" class="bg-white divide-y divide-gray-200">
117
+ <!-- Populated by JavaScript -->
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+
122
+ <!-- Pagination -->
123
+ <div id="pagination" class="hidden mt-4 flex items-center justify-between">
124
+ <div class="flex-1 flex justify-between sm:hidden">
125
+ <button id="prev-page-mobile" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">Previous</button>
126
+ <button id="next-page-mobile" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">Next</button>
127
+ </div>
128
+ <div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
129
+ <div>
130
+ <p class="text-sm text-gray-700">
131
+ Showing <span id="page-start">1</span> to <span id="page-end">25</span> of <span id="total-jobs">0</span> jobs
132
+ </p>
133
+ </div>
134
+ <div>
135
+ <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination" id="page-buttons">
136
+ <!-- Populated by JavaScript -->
137
+ </nav>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ {% endblock %}
143
+
144
+ {% block scripts %}
145
+ <script src="/static/js/token.js"></script>
146
+ <script src="/static/js/history.js"></script>
147
+ {% endblock %}
webapp/templates/index.html CHANGED
@@ -9,106 +9,135 @@
9
  <h1 class="text-3xl font-bold text-gray-900 sm:text-4xl">
10
  RNA Splicing Predictor
11
  </h1>
12
- <p class="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
13
- Predict alternative splicing outcomes using an interpretable deep learning model.
14
- Enter a 70-nucleotide exon sequence to get a PSI (Percent Spliced In) prediction.
15
- </p>
16
  </div>
17
 
18
- <!-- Quick Info Cards -->
19
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
20
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
21
- <div class="flex items-center">
22
- <div class="flex-shrink-0">
23
- <svg class="h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
24
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
25
- </svg>
26
- </div>
27
- <div class="ml-4">
28
- <h3 class="text-sm font-medium text-gray-900">Input</h3>
29
- <p class="text-sm text-gray-500">70nt exon sequence</p>
30
- </div>
31
- </div>
32
- </div>
33
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
34
- <div class="flex items-center">
35
- <div class="flex-shrink-0">
36
- <svg class="h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
37
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
38
- </svg>
39
- </div>
40
- <div class="ml-4">
41
- <h3 class="text-sm font-medium text-gray-900">Output</h3>
42
- <p class="text-sm text-gray-500">PSI value (0-1)</p>
43
- </div>
44
- </div>
45
- </div>
46
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
47
- <div class="flex items-center">
48
- <div class="flex-shrink-0">
49
- <svg class="h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
50
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
51
- </svg>
52
- </div>
53
- <div class="ml-4">
54
- <h3 class="text-sm font-medium text-gray-900">Speed</h3>
55
- <p class="text-sm text-gray-500">Results in seconds</p>
56
  </div>
57
- </div>
 
 
 
 
58
  </div>
59
  </div>
60
 
61
  <!-- Prediction Form -->
62
  <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
63
  <form id="predict-form">
 
64
  <div class="mb-4">
65
- <label for="sequence" class="block text-sm font-medium text-gray-700 mb-2">
66
- Exon Sequence
67
- <span class="text-gray-400 font-normal">(exactly 70 nucleotides, A/C/G/T only)</span>
 
 
 
 
 
 
 
68
  </label>
69
- <textarea
70
- id="sequence"
71
- name="sequence"
72
- rows="3"
73
- class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono text-sm p-3 border"
74
- placeholder="Enter your 70-nucleotide exon sequence here...
75
-
76
- Example: GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT"
77
- maxlength="70"
78
- ></textarea>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  <div class="mt-2 flex justify-between items-center">
80
- <span id="char-count" class="text-sm text-gray-500">0/70 nucleotides</span>
81
  <span id="validation-message" class="text-sm text-red-500 hidden"></span>
82
  </div>
83
  </div>
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  <div class="flex flex-wrap gap-3">
86
- <button
87
- type="submit"
88
- id="submit-btn"
89
- class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
90
- >
91
  <svg class="hidden animate-spin -ml-1 mr-2 h-4 w-4 text-white" id="loading-spinner" fill="none" viewBox="0 0 24 24">
92
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
93
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
94
  </svg>
95
  <span id="submit-text">Predict PSI</span>
96
  </button>
97
- <button
98
- type="button"
99
- id="example-btn"
100
- class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
101
- >
102
  <svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
103
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
104
  </svg>
105
  Try Example
106
  </button>
107
- <button
108
- type="button"
109
- id="clear-btn"
110
- class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
111
- >
112
  <svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
113
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
114
  </svg>
@@ -128,48 +157,291 @@ Example: GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT"
128
  </div>
129
  </div>
130
 
131
- <!-- Example Sequences -->
132
- <div class="mt-8">
133
- <h2 class="text-lg font-medium text-gray-900 mb-4">Example Sequences</h2>
134
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
135
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:border-primary-300 transition-colors" onclick="loadExample(0)">
136
- <div class="flex items-center justify-between mb-2">
137
- <span class="text-sm font-medium text-gray-900">High Inclusion</span>
138
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">PSI ~0.98</span>
139
- </div>
140
- <p class="text-xs text-gray-500 font-mono truncate">GGTAGTACGCCAATTCGCCG...</p>
141
- </div>
142
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:border-primary-300 transition-colors" onclick="loadExample(1)">
143
- <div class="flex items-center justify-between mb-2">
144
- <span class="text-sm font-medium text-gray-900">Balanced</span>
145
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">PSI ~0.49</span>
146
- </div>
147
- <p class="text-xs text-gray-500 font-mono truncate">CTACCACCTCCCAAGCTTAC...</p>
148
- </div>
149
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:border-primary-300 transition-colors" onclick="loadExample(2)">
150
- <div class="flex items-center justify-between mb-2">
151
- <span class="text-sm font-medium text-gray-900">High Skipping</span>
152
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">PSI ~0.00</span>
153
- </div>
154
- <p class="text-xs text-gray-500 font-mono truncate">ACACTCCGCAGCACACTCGG...</p>
155
- </div>
156
- </div>
157
- </div>
158
-
159
- <!-- Learn More -->
160
- <div class="mt-8 text-center">
161
- <p class="text-sm text-gray-500">
162
- Want to learn more?
163
- <a href="/about" class="text-primary-600 hover:text-primary-700 font-medium">About the model</a>
164
- <span class="mx-2">|</span>
165
- <a href="/methodology" class="text-primary-600 hover:text-primary-700 font-medium">How it works</a>
166
- <span class="mx-2">|</span>
167
- <a href="/tutorial" class="text-primary-600 hover:text-primary-700 font-medium">Tutorial</a>
168
- </p>
169
- </div>
170
  </div>
171
  {% endblock %}
172
 
173
  {% block scripts %}
174
- <script src="/static/js/app.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  {% endblock %}
 
9
  <h1 class="text-3xl font-bold text-gray-900 sm:text-4xl">
10
  RNA Splicing Predictor
11
  </h1>
 
 
 
 
12
  </div>
13
 
14
+ <!-- Token Display Section -->
15
+ <div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
16
+ <div class="flex items-center justify-center gap-2">
17
+ <span class="relative group text-sm font-medium text-blue-900 cursor-help underline decoration-dotted underline-offset-2">
18
+ Your Access Token:
19
+ <div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
20
+ <p>This unique token identifies your jobs. Save it to access your job history from any device or browser.</p>
21
+ <p class="mt-2 text-gray-300">Double-click the token to edit it.</p>
22
+ <div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  </div>
24
+ </span>
25
+ <code id="token-display" class="bg-white px-3 py-1 rounded border border-blue-200 text-sm font-mono text-blue-800"></code>
26
+ <button type="button" id="token-copy-btn" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
27
+ Copy
28
+ </button>
29
  </div>
30
  </div>
31
 
32
  <!-- Prediction Form -->
33
  <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
34
  <form id="predict-form">
35
+ <!-- Job Title -->
36
  <div class="mb-4">
37
+ <label for="job-title" class="block text-sm text-gray-700 mb-2">
38
+ <span class="relative group font-medium cursor-help underline decoration-dotted underline-offset-2">
39
+ Job Title
40
+ <div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
41
+ <p>Give your prediction job a memorable name to easily find it later in your job history.</p>
42
+ <p class="mt-2 text-gray-300">If left empty, a name will be auto-generated (e.g., "2026-01-15_abc12").</p>
43
+ <div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
44
+ </div>
45
+ </span>
46
+ <span class="text-gray-400 font-normal">(optional)</span>
47
  </label>
48
+ <input type="text" id="job-title" name="job_title"
49
+ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm p-2 border"
50
+ placeholder="e.g., BRCA1 Exon Analysis">
51
+ </div>
52
+
53
+ <!-- Sequence Input -->
54
+ <div class="mb-4">
55
+ <label for="sequences" class="block text-sm text-gray-700 mb-2">
56
+ <span class="relative group font-medium cursor-help underline decoration-dotted underline-offset-2">
57
+ Sequence(s)
58
+ <div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
59
+ <p class="font-semibold mb-1">How to enter sequences:</p>
60
+ <ul class="list-disc list-inside space-y-1">
61
+ <li><strong>Single sequence:</strong> Paste a 70-nucleotide sequence directly</li>
62
+ <li><strong>Multiple (FASTA):</strong> Use &gt;name on one line, sequence on next</li>
63
+ <li><strong>Multiple (plain):</strong> One sequence per line</li>
64
+ </ul>
65
+ <p class="mt-2 text-gray-300">Only A, C, G, T, U characters allowed. Each sequence must be exactly 70 nucleotides.</p>
66
+ <div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
67
+ </div>
68
+ </span>
69
+ <span class="text-gray-400 font-normal">(70nt each, A/C/G/T/U, max 100)</span>
70
+ </label>
71
+ <textarea id="sequences" name="sequences" rows="8"
72
+ class="block w-full rounded-md border-gray-300 shadow-sm font-mono text-sm p-3 border focus:border-primary-500 focus:ring-primary-500"
73
+ placeholder="Paste one or more sequences:
74
+
75
+ Single sequence:
76
+ GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT
77
+
78
+ Multiple sequences (FASTA format):
79
+ >Gene1_Exon5
80
+ GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT
81
+ >Gene2_Exon3
82
+ CTACCACCTCCCAAGCTTACACACTGTTTGATGAAAGGTCGCCACAACGTTCCCTCACCCCTAGTCTCGC
83
+
84
+ Or plain sequences (one per line):
85
+ GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT
86
+ CTACCACCTCCCAAGCTTACACACTGTTTGATGAAAGGTCGCCACAACGTTCCCTCACCCCTAGTCTCGC"></textarea>
87
  <div class="mt-2 flex justify-between items-center">
88
+ <span id="sequence-count" class="text-sm text-gray-500">0 sequences detected</span>
89
  <span id="validation-message" class="text-sm text-red-500 hidden"></span>
90
  </div>
91
  </div>
92
 
93
+ <!-- File Upload -->
94
+ <div class="mb-6">
95
+ <label class="block text-sm text-gray-700 mb-2">
96
+ <span class="relative group font-medium cursor-help underline decoration-dotted underline-offset-2">
97
+ Or Upload File
98
+ <div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
99
+ <p class="font-semibold mb-1">Supported file formats:</p>
100
+ <ul class="list-disc list-inside space-y-1">
101
+ <li><strong>CSV:</strong> 1 column (sequences only) or 2 columns (name, sequence)</li>
102
+ <li><strong>FASTA:</strong> Standard format with &gt;header lines</li>
103
+ </ul>
104
+ <p class="mt-2 text-gray-300">Delimiters (comma, semicolon, tab) and headers are auto-detected.</p>
105
+ <div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
106
+ </div>
107
+ </span>
108
+ <span class="text-gray-400 font-normal">(CSV or FASTA)</span>
109
+ </label>
110
+ <div class="flex items-center gap-4">
111
+ <input type="file" id="file-upload" accept=".csv,.fasta,.fa,.fna,.txt,.tsv"
112
+ class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 cursor-pointer">
113
+ <button type="button" id="clear-file-btn" class="text-gray-400 hover:text-gray-600 hidden">
114
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
115
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
116
+ </svg>
117
+ </button>
118
+ </div>
119
+ <p id="file-status" class="mt-1 text-sm text-gray-500"></p>
120
+ </div>
121
+
122
+ <!-- Action Buttons -->
123
  <div class="flex flex-wrap gap-3">
124
+ <button type="submit" id="submit-btn"
125
+ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed">
 
 
 
126
  <svg class="hidden animate-spin -ml-1 mr-2 h-4 w-4 text-white" id="loading-spinner" fill="none" viewBox="0 0 24 24">
127
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
128
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
129
  </svg>
130
  <span id="submit-text">Predict PSI</span>
131
  </button>
132
+ <button type="button" id="example-btn"
133
+ class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
 
 
 
134
  <svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
135
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
136
  </svg>
137
  Try Example
138
  </button>
139
+ <button type="button" id="clear-btn"
140
+ class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
 
 
 
141
  <svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
142
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
143
  </svg>
 
157
  </div>
158
  </div>
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  </div>
161
  {% endblock %}
162
 
163
  {% block scripts %}
164
+ <script src="/static/js/token.js"></script>
165
+ <script src="/static/js/file-parser.js"></script>
166
+ <script>
167
+ // Example sequences
168
+ const EXAMPLES = [
169
+ {
170
+ name: "High Inclusion",
171
+ sequence: "GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT",
172
+ expectedPsi: "~0.98"
173
+ },
174
+ {
175
+ name: "Balanced",
176
+ sequence: "CTACCACCTCCCAAGCTTACACACTGTTTGATGAAAGGTCGCCACAACGTTCCCTCACCCCTAGTCTCGC",
177
+ expectedPsi: "~0.49"
178
+ },
179
+ {
180
+ name: "High Skipping",
181
+ sequence: "ACACTCCGCAGCACACTCGGCAAAGAAGTTAGGCCCCGCTCTTACAAACATCTAGCATTTTGTATGGTCT",
182
+ expectedPsi: "~0.00"
183
+ }
184
+ ];
185
+
186
+ // DOM Elements
187
+ const form = document.getElementById('predict-form');
188
+ const sequencesInput = document.getElementById('sequences');
189
+ const jobTitleInput = document.getElementById('job-title');
190
+ const sequenceCount = document.getElementById('sequence-count');
191
+ const validationMessage = document.getElementById('validation-message');
192
+ const submitBtn = document.getElementById('submit-btn');
193
+ const submitText = document.getElementById('submit-text');
194
+ const loadingSpinner = document.getElementById('loading-spinner');
195
+ const errorMessage = document.getElementById('error-message');
196
+ const errorText = document.getElementById('error-text');
197
+ const exampleBtn = document.getElementById('example-btn');
198
+ const clearBtn = document.getElementById('clear-btn');
199
+ const fileUpload = document.getElementById('file-upload');
200
+ const clearFileBtn = document.getElementById('clear-file-btn');
201
+ const fileStatus = document.getElementById('file-status');
202
+
203
+ let currentExampleIndex = 0;
204
+ let parsedSequences = [];
205
+
206
+ // Initialize token display
207
+ document.addEventListener('DOMContentLoaded', () => {
208
+ TokenManager.initTokenDisplay();
209
+ updateSequenceCount();
210
+ });
211
+
212
+ /**
213
+ * Update sequence count and validation
214
+ */
215
+ function updateSequenceCount() {
216
+ const text = sequencesInput.value;
217
+ if (!text.trim()) {
218
+ sequenceCount.textContent = '0 sequences detected';
219
+ sequenceCount.className = 'text-sm text-gray-500';
220
+ parsedSequences = [];
221
+ updateSubmitButton();
222
+ return;
223
+ }
224
+
225
+ const result = FileParser.parseAndValidate(text, 70);
226
+ parsedSequences = result.sequences;
227
+
228
+ if (parsedSequences.length === 0) {
229
+ sequenceCount.textContent = '0 sequences detected';
230
+ sequenceCount.className = 'text-sm text-gray-500';
231
+ } else if (parsedSequences.length === 1) {
232
+ if (result.validCount === 1) {
233
+ sequenceCount.textContent = '1 valid sequence';
234
+ sequenceCount.className = 'text-sm text-green-600';
235
+ } else {
236
+ sequenceCount.textContent = '1 sequence (invalid)';
237
+ sequenceCount.className = 'text-sm text-red-600';
238
+ }
239
+ } else {
240
+ if (result.invalidCount === 0) {
241
+ sequenceCount.textContent = `${parsedSequences.length} valid sequences`;
242
+ sequenceCount.className = 'text-sm text-green-600';
243
+ } else {
244
+ sequenceCount.textContent = `${parsedSequences.length} sequences (${result.validCount} valid, ${result.invalidCount} invalid)`;
245
+ sequenceCount.className = 'text-sm text-yellow-600';
246
+ }
247
+ }
248
+
249
+ updateSubmitButton();
250
+ }
251
+
252
+ /**
253
+ * Update submit button state
254
+ */
255
+ function updateSubmitButton() {
256
+ // Enable if we have at least one sequence (valid or not - backend will handle)
257
+ submitBtn.disabled = parsedSequences.length === 0;
258
+ }
259
+
260
+ /**
261
+ * Show loading state
262
+ */
263
+ function setLoading(loading) {
264
+ if (loading) {
265
+ submitBtn.disabled = true;
266
+ submitText.textContent = 'Processing...';
267
+ loadingSpinner.classList.remove('hidden');
268
+ } else {
269
+ updateSubmitButton();
270
+ submitText.textContent = 'Predict PSI';
271
+ loadingSpinner.classList.add('hidden');
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Show error message
277
+ */
278
+ function showError(message) {
279
+ errorText.textContent = message;
280
+ errorMessage.classList.remove('hidden');
281
+ }
282
+
283
+ /**
284
+ * Hide error message
285
+ */
286
+ function hideError() {
287
+ errorMessage.classList.add('hidden');
288
+ }
289
+
290
+ /**
291
+ * Load an example sequence
292
+ */
293
+ function loadExample(index) {
294
+ const example = EXAMPLES[index];
295
+ sequencesInput.value = `>${example.name}\n${example.sequence}`;
296
+ updateSequenceCount();
297
+ hideError();
298
+ }
299
+
300
+ /**
301
+ * Handle file upload
302
+ */
303
+ async function handleFileUpload(event) {
304
+ const file = event.target.files[0];
305
+ if (!file) return;
306
+
307
+ try {
308
+ fileStatus.textContent = 'Parsing file...';
309
+ const sequences = await FileParser.parseFile(file);
310
+
311
+ if (sequences.length === 0) {
312
+ fileStatus.textContent = 'No sequences found in file';
313
+ fileStatus.className = 'mt-1 text-sm text-red-500';
314
+ return;
315
+ }
316
+
317
+ // Convert to FASTA format and put in textarea
318
+ const fastaText = sequences.map(s => `>${s.name}\n${s.sequence}`).join('\n');
319
+ sequencesInput.value = fastaText;
320
+ updateSequenceCount();
321
+
322
+ fileStatus.textContent = `Loaded ${sequences.length} sequences from ${file.name}`;
323
+ fileStatus.className = 'mt-1 text-sm text-green-600';
324
+ clearFileBtn.classList.remove('hidden');
325
+ hideError();
326
+
327
+ } catch (error) {
328
+ fileStatus.textContent = `Error: ${error.message}`;
329
+ fileStatus.className = 'mt-1 text-sm text-red-500';
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Clear file input
335
+ */
336
+ function clearFileInput() {
337
+ fileUpload.value = '';
338
+ fileStatus.textContent = '';
339
+ clearFileBtn.classList.add('hidden');
340
+ }
341
+
342
+ /**
343
+ * Submit the prediction
344
+ */
345
+ async function submitPrediction(event) {
346
+ event.preventDefault();
347
+
348
+ if (parsedSequences.length === 0) {
349
+ showError('Please enter at least one sequence');
350
+ return;
351
+ }
352
+
353
+ setLoading(true);
354
+ hideError();
355
+
356
+ const token = TokenManager.getOrCreateToken();
357
+ const jobTitle = jobTitleInput.value.trim() || null;
358
+
359
+ try {
360
+ let response;
361
+ let result;
362
+
363
+ if (parsedSequences.length === 1) {
364
+ // Single sequence prediction
365
+ const seq = parsedSequences[0];
366
+ response = await fetch('/api/predict', {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify({
370
+ sequence: seq.sequence,
371
+ name: seq.name,
372
+ access_token: token,
373
+ job_title: jobTitle
374
+ })
375
+ });
376
+ } else {
377
+ // Batch prediction
378
+ const sequences = parsedSequences.map(s => ({
379
+ name: s.name,
380
+ sequence: s.sequence
381
+ }));
382
+ response = await fetch('/api/batch', {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({
386
+ sequences: sequences,
387
+ access_token: token,
388
+ job_title: jobTitle
389
+ })
390
+ });
391
+ }
392
+
393
+ if (!response.ok) {
394
+ const error = await response.json();
395
+ throw new Error(error.detail || 'Prediction failed');
396
+ }
397
+
398
+ result = await response.json();
399
+
400
+ // Redirect to result page
401
+ window.location.href = `/result/${result.job_id}`;
402
+
403
+ } catch (error) {
404
+ console.error('Prediction error:', error);
405
+ showError(error.message || 'An error occurred. Please try again.');
406
+ setLoading(false);
407
+ }
408
+ }
409
+
410
+ // Event listeners
411
+ if (form) {
412
+ form.addEventListener('submit', submitPrediction);
413
+ }
414
+
415
+ if (sequencesInput) {
416
+ sequencesInput.addEventListener('input', updateSequenceCount);
417
+ }
418
+
419
+ if (exampleBtn) {
420
+ exampleBtn.addEventListener('click', () => {
421
+ loadExample(currentExampleIndex);
422
+ currentExampleIndex = (currentExampleIndex + 1) % EXAMPLES.length;
423
+ });
424
+ }
425
+
426
+ if (clearBtn) {
427
+ clearBtn.addEventListener('click', () => {
428
+ sequencesInput.value = '';
429
+ jobTitleInput.value = '';
430
+ updateSequenceCount();
431
+ clearFileInput();
432
+ hideError();
433
+ });
434
+ }
435
+
436
+ if (fileUpload) {
437
+ fileUpload.addEventListener('change', handleFileUpload);
438
+ }
439
+
440
+ if (clearFileBtn) {
441
+ clearFileBtn.addEventListener('click', clearFileInput);
442
+ }
443
+
444
+ // Make loadExample available globally for onclick handlers
445
+ window.loadExample = loadExample;
446
+ </script>
447
  {% endblock %}
webapp/templates/result.html CHANGED
@@ -81,6 +81,26 @@
81
  <div id="force-plot" class="w-full" style="height: 400px;"></div>
82
  </div>
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  <!-- Actions -->
85
  <div class="flex flex-wrap gap-3">
86
  <a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
 
81
  <div id="force-plot" class="w-full" style="height: 400px;"></div>
82
  </div>
83
 
84
+ <!-- Filter Activation Heatmap -->
85
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
86
+ <h3 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-4">
87
+ Filter Activation Heatmap
88
+ <span class="ml-2 text-gray-400 font-normal normal-case">
89
+ (shows where each neural network filter activates across the sequence)
90
+ </span>
91
+ </h3>
92
+ <div id="heatmap-container" class="w-full" style="min-height: 500px;">
93
+ <iframe
94
+ id="heatmap-iframe"
95
+ src="/shiny/heatmap/?job_id={{ job_id }}"
96
+ class="w-full border-0"
97
+ style="height: 600px; min-height: 500px;"
98
+ loading="lazy"
99
+ title="Filter Activation Heatmap"
100
+ ></iframe>
101
+ </div>
102
+ </div>
103
+
104
  <!-- Actions -->
105
  <div class="flex flex-wrap gap-3">
106
  <a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">