AE-Shree commited on
Commit
bda3c09
Β·
1 Parent(s): 51e834c

Deploy BioStack RLHF Medical Demo

Browse files
.do/app.yaml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: biostack-rlhf-demo
2
+ services:
3
+ - name: api
4
+ source_dir: /
5
+ github:
6
+ repo: AE-Shree/Biostack # Your GitHub repo
7
+ branch: main
8
+ deploy_on_push: true
9
+ run_command: python server.py
10
+ instance_count: 1
11
+ instance_size_slug: professional-xs
12
+ envs:
13
+ - key: HUGGINGFACE_TOKEN
14
+ type: SECRET # Set this in DigitalOcean dashboard
15
+ health_check:
16
+ http_path: /health
17
+ cors:
18
+ allow_origins:
19
+ - '*'
20
+ allow_methods:
21
+ - GET
22
+ - POST
23
+ - PUT
24
+ - DELETE
25
+ - OPTIONS
26
+ allow_headers:
27
+ - '*'
28
+
29
+ - name: frontend
30
+ source_dir: /
31
+ github:
32
+ repo: AE-Shree/Biostack # Your GitHub repo
33
+ branch: main
34
+ deploy_on_push: true
35
+ build_command: npm install && npm run build
36
+ static_site_generator:
37
+ github_actions:
38
+ build_command: npm install && npm run build
39
+ output_dir: build
40
+ catchall_document: index.html
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # production
12
+ /build
13
+
14
+ # misc
15
+ .DS_Store
16
+ .env.local
17
+ .env.development.local
18
+ .env.test.local
19
+ .env.production.local
20
+
21
+ npm-debug.log*
22
+ yarn-debug.log*
23
+ yarn-error.log*
24
+
25
+ models/
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install Node.js for building React app
4
+ RUN apt-get update && apt-get install -y \
5
+ nodejs \
6
+ npm \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Set working directory
10
+ WORKDIR /app
11
+
12
+ # Copy requirements first for better caching
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy package files and install dependencies
17
+ COPY package*.json ./
18
+ RUN npm install
19
+
20
+ # Copy all files
21
+ COPY . .
22
+
23
+ # Build React app
24
+ RUN npm run build
25
+
26
+ # Expose port
27
+ EXPOSE 7860
28
+
29
+ # Run both frontend and backend
30
+ CMD ["python", "server.py"]
README.md CHANGED
@@ -1,11 +1,64 @@
1
  ---
2
- title: BioStack
3
- emoji: πŸ‘
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: BioStack - RLHF Medical Report Generation
3
+ emoji: 🩻
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
 
8
  ---
9
 
10
+ # BioStack - RLHF Medical Report Generation
11
+
12
+ AI-powered medical report generation using Reinforcement Learning from Human Feedback (RLHF).
13
+
14
+ ## Features
15
+ - **SFT Model**: Supervised Fine-Tuning for initial report generation
16
+ - **Reward Model**: Quality assessment of generated reports
17
+ - **PPO Model**: Policy optimization for improved outputs
18
+ - **Modern UI**: Clean React interface with drag-and-drop upload
19
+
20
+ ## Models Used
21
+ - **SFT Model**: `best_model.pt` - Initial report generation
22
+ - **Reward Model**: `reward_model.pt` - Quality scoring
23
+ - **PPO Model**: `rlhf_model.pt` - Optimized generation
24
+
25
+ All models are automatically downloaded from Hugging Face Hub on first startup.
26
+
27
+ ## Usage
28
+ 1. Upload a medical X-ray image
29
+ 2. Optionally provide ground truth text for comparison
30
+ 3. Click "Run Inference" to generate reports
31
+ 4. View results from all three models with quality scores
32
+
33
+ ## Technology Stack
34
+ - **Frontend**: React.js
35
+ - **Backend**: FastAPI (Python)
36
+ - **ML Framework**: PyTorch, Transformers
37
+ - **Models**: T5, CoAtNet, Custom Reward Model
38
+
39
+ ## Local Development
40
+
41
+ ### Prerequisites
42
+ - Python 3.11+
43
+ - Node.js 16+
44
+ - Hugging Face account with access tokens
45
+
46
+ ### Installation
47
+ ```bash
48
+ # Install Python dependencies
49
+ pip install -r requirements.txt
50
+
51
+ # Install Node dependencies
52
+ npm install
53
+
54
+ # Build React app
55
+ npm run build
56
+
57
+ # Run server
58
+ python server.py
59
+ ```
60
+
61
+ The app will be available at `http://localhost:7860`
62
+
63
+ ## Deployment
64
+ This app is configured for deployment on Hugging Face Spaces using Docker.
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "rlhf-demo",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@testing-library/dom": "^10.4.1",
7
+ "@testing-library/jest-dom": "^6.9.1",
8
+ "@testing-library/react": "^16.3.2",
9
+ "@testing-library/user-event": "^13.5.0",
10
+ "react": "^19.2.4",
11
+ "react-dom": "^19.2.4",
12
+ "react-scripts": "5.0.1",
13
+ "web-vitals": "^2.1.4"
14
+ },
15
+ "scripts": {
16
+ "start": "react-scripts start",
17
+ "build": "react-scripts build",
18
+ "test": "react-scripts test",
19
+ "eject": "react-scripts eject"
20
+ },
21
+ "eslintConfig": {
22
+ "extends": [
23
+ "react-app",
24
+ "react-app/jest"
25
+ ]
26
+ },
27
+ "browserslist": {
28
+ "production": [
29
+ ">0.2%",
30
+ "not dead",
31
+ "not op_mini all"
32
+ ],
33
+ "development": [
34
+ "last 1 chrome version",
35
+ "last 1 firefox version",
36
+ "last 1 safari version"
37
+ ]
38
+ }
39
+ }
public/favicon.ico ADDED
public/favicon.svg ADDED
public/index.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%PUBLIC_URL%/favicon.svg?v=1" type="image/svg+xml" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="theme-color" content="#000000" />
8
+ <meta
9
+ name="description"
10
+ content="BioStack - AI-powered medical report generation using RLHF technology"
11
+ />
12
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
+ <!--
14
+ manifest.json provides metadata used when your web app is installed on a
15
+ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16
+ -->
17
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18
+ <!--
19
+ Notice the use of %PUBLIC_URL% in the tags above.
20
+ It will be replaced with the URL of the `public` folder during the build.
21
+ Only files inside the `public` folder can be referenced from the HTML.
22
+
23
+ Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
24
+ work correctly both with client-side routing and a non-root public URL.
25
+ Learn how to configure a non-root public URL by running `npm run build`.
26
+ -->
27
+ <title>BioStack</title>
28
+ </head>
29
+ <body>
30
+ <noscript>You need to enable JavaScript to run this app.</noscript>
31
+ <div id="root"></div>
32
+ <!--
33
+ This HTML file is a template.
34
+ If you open it directly in the browser, you will see an empty page.
35
+
36
+ You can add webfonts, meta tags, or analytics to this file.
37
+ The build step will place the bundled scripts into the <body> tag.
38
+
39
+ To begin the development, run `npm start` or `yarn start`.
40
+ To create a production bundle, use `npm run build` or `yarn build`.
41
+ -->
42
+ </body>
43
+ </html>
public/logo192.png ADDED
public/logo512.png ADDED
public/manifest.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "short_name": "BioStack",
3
+ "name": "BioStack - RLHF Medical Report Generation",
4
+ "icons": [
5
+ {
6
+ "src": "favicon.ico",
7
+ "sizes": "64x64 32x32 24x24 16x16",
8
+ "type": "image/x-icon"
9
+ },
10
+ {
11
+ "src": "logo192.png",
12
+ "type": "image/png",
13
+ "sizes": "192x192"
14
+ },
15
+ {
16
+ "src": "logo512.png",
17
+ "type": "image/png",
18
+ "sizes": "512x512"
19
+ }
20
+ ],
21
+ "start_url": ".",
22
+ "display": "standalone",
23
+ "theme_color": "#2563eb",
24
+ "background_color": "#ffffff"
25
+ }
public/robots.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
requirements.txt ADDED
Binary file (1.73 kB). View file
 
server.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import torch
3
+ import torch.nn as nn
4
+ import timm
5
+ import pickle
6
+ import traceback
7
+ import os
8
+ from PIL import Image
9
+ from fastapi import FastAPI, File, UploadFile
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from torchvision import transforms
12
+ from transformers import T5ForConditionalGeneration, T5Tokenizer
13
+ from huggingface_hub import hf_hub_download
14
+
15
+ # ─────────────────────────────────────────────────────────────────────────────
16
+ # DEVICE
17
+ # ─────────────────────────────────────────────────────────────────────────────
18
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
19
+ print(f"πŸ–₯️ Using device: {device}")
20
+
21
+ # ─────────────────────────────────────────────────────────────────────────────
22
+ # SHARED TOKENIZER
23
+ # ─────────────────────────────────────────────────────────────────────────────
24
+ tokenizer = T5Tokenizer.from_pretrained("t5-small", legacy=False)
25
+
26
+ # ─────────────────────────────────────────────────────────────────────────────
27
+ # ARCHITECTURE 1 β€” CoAtNet Encoder (shared by all three models)
28
+ # Matches BOTH notebooks exactly.
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+ class CoAtNetEncoder(nn.Module):
31
+ def __init__(self, model_name="coatnet_1_rw_224", pretrained=False, train_last_stages=2):
32
+ super().__init__()
33
+ # pretrained=False at inference time β€” weights come from .pt file
34
+ self.backbone = timm.create_model(model_name, pretrained=pretrained)
35
+
36
+ for name, param in self.backbone.named_parameters():
37
+ param.requires_grad = False
38
+ for i in range(5 - train_last_stages, 5):
39
+ if f"stages.{i}" in name:
40
+ param.requires_grad = True
41
+ break
42
+
43
+ # Detect feature_dim dynamically (same as RM/PPO notebook Cell 4)
44
+ with torch.no_grad():
45
+ dummy = torch.randn(1, 3, 224, 224)
46
+ features = self.backbone.forward_features(dummy)
47
+ if len(features.shape) == 4:
48
+ features = features.mean(dim=[2, 3])
49
+ self.feature_dim = features.shape[-1]
50
+
51
+ print(f" CoAtNetEncoder feature_dim = {self.feature_dim}")
52
+
53
+ def forward(self, x):
54
+ features = self.backbone.forward_features(x)
55
+ if len(features.shape) == 4:
56
+ features = features.mean(dim=[2, 3])
57
+ return features
58
+
59
+
60
+ # ─────────────────────────────────────────────────────────────────────────────
61
+ # ARCHITECTURE 2 β€” SFT VisionT5Model
62
+ # BUG FIX: Uses self.t5 and self.proj β€” exactly matching best_model.pt keys
63
+ # from SFT notebook Cell 33. Do NOT rename these to txt_model/img_proj.
64
+ # ─────────────────────────────────────────────────────────────────────────────
65
+ class SFTVisionT5Model(nn.Module):
66
+ def __init__(self, img_encoder, txt_model_name="t5-small", img_emb_dim=768):
67
+ super().__init__()
68
+ self.img_encoder = img_encoder
69
+ # ← self.t5 (NOT self.txt_model β€” must match saved keys)
70
+ self.t5 = T5ForConditionalGeneration.from_pretrained(txt_model_name)
71
+ # ← self.proj (NOT self.img_proj β€” must match saved keys)
72
+ self.proj = nn.Linear(img_emb_dim, self.t5.config.d_model)
73
+
74
+ for p in self.t5.shared.parameters():
75
+ p.requires_grad = False
76
+
77
+ def generate_reports(self, pixel_values, max_length=100):
78
+ self.eval()
79
+ with torch.no_grad():
80
+ # Extract + project image features
81
+ img_feats = self.img_encoder(pixel_values) # [B, feature_dim]
82
+ img_feats = self.proj(img_feats) # [B, d_model]
83
+ encoder_hidden_states = img_feats.unsqueeze(1) # [B, 1, d_model]
84
+
85
+ # Encode
86
+ encoder_outputs = self.t5.encoder(
87
+ inputs_embeds=encoder_hidden_states
88
+ )
89
+
90
+ attn = torch.ones(
91
+ encoder_hidden_states.size()[:2], device=pixel_values.device
92
+ )
93
+
94
+ # BUG FIX 3: repetition_penalty + no_repeat_ngram_size breaks
95
+ # the "Projection: Projection: Projection:" loop
96
+ generated_ids = self.t5.generate(
97
+ encoder_outputs=encoder_outputs,
98
+ attention_mask=attn,
99
+ max_length=max_length,
100
+ num_beams=4,
101
+ early_stopping=True,
102
+ no_repeat_ngram_size=3,
103
+ repetition_penalty=1.3,
104
+ )
105
+
106
+ reports = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)
107
+ # Strip any leading "Projection: X." prefix that leaked from training data
108
+ cleaned = []
109
+ for r in reports:
110
+ if r.lower().startswith("projection:"):
111
+ # Remove the first "Projection: X." segment
112
+ parts = r.split(".", 1)
113
+ r = parts[1].strip() if len(parts) > 1 else r
114
+ cleaned.append(r)
115
+ return cleaned
116
+
117
+
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+ # ARCHITECTURE 3 β€” PPO VisionT5Model
120
+ # Uses self.txt_model and self.img_proj β€” matching RM/PPO notebook Cell 4.
121
+ # ─────────────────────────────────────────────────────────────────────────────
122
+ class PPOVisionT5Model(nn.Module):
123
+ def __init__(self, img_encoder, txt_model_name="t5-small", img_emb_dim=768):
124
+ super().__init__()
125
+ self.img_encoder = img_encoder
126
+ # ← self.txt_model (matches PPO notebook Cell 4)
127
+ self.txt_model = T5ForConditionalGeneration.from_pretrained(txt_model_name)
128
+ # ← self.img_proj (matches PPO notebook Cell 4)
129
+ self.img_proj = nn.Linear(img_emb_dim, self.txt_model.config.d_model)
130
+
131
+ def generate_reports(self, images, max_length=128):
132
+ self.eval()
133
+ with torch.no_grad():
134
+ img_features = self.img_encoder(images) # [B, feature_dim]
135
+ img_emb = self.img_proj(img_features).unsqueeze(1) # [B, 1, d_model]
136
+
137
+ batch_size = images.size(0)
138
+ img_attn = torch.ones(batch_size, 1, device=images.device)
139
+
140
+ encoder_outputs = self.txt_model.encoder(
141
+ inputs_embeds=img_emb,
142
+ attention_mask=img_attn
143
+ )
144
+
145
+ # BUG FIX 3: same repetition guards as SFT
146
+ generated = self.txt_model.generate(
147
+ encoder_outputs=encoder_outputs,
148
+ attention_mask=img_attn,
149
+ max_length=max_length,
150
+ num_beams=4,
151
+ early_stopping=True,
152
+ no_repeat_ngram_size=3,
153
+ repetition_penalty=1.3,
154
+ )
155
+
156
+ reports = tokenizer.batch_decode(generated, skip_special_tokens=True)
157
+ # Strip any leading "Projection: X." prefix that leaked from training data
158
+ cleaned = []
159
+ for r in reports:
160
+ if r.lower().startswith("projection:"):
161
+ # Remove the first "Projection: X." segment
162
+ parts = r.split(".", 1)
163
+ r = parts[1].strip() if len(parts) > 1 else r
164
+ cleaned.append(r)
165
+ return cleaned
166
+
167
+
168
+ # ─────────────────────────────────────────────────────────────────────────────
169
+ # ARCHITECTURE 4 β€” Reward Model
170
+ # Matches RM/PPO notebook Cell 5 exactly.
171
+ # ─────────────────────────────────────────────────────────────────────────────
172
+ class RewardModel(nn.Module):
173
+ def __init__(self, img_encoder, txt_model_name="t5-small"):
174
+ super().__init__()
175
+ self.img_encoder = img_encoder
176
+ self.txt_encoder = T5ForConditionalGeneration.from_pretrained(txt_model_name).encoder
177
+ img_dim = img_encoder.feature_dim
178
+ txt_dim = self.txt_encoder.config.d_model
179
+ self.img_proj = nn.Linear(img_dim, 512)
180
+ self.txt_proj = nn.Linear(txt_dim, 512)
181
+ self.reward_head = nn.Sequential(
182
+ nn.Linear(1024, 512), nn.ReLU(), nn.Dropout(0.1),
183
+ nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.1),
184
+ nn.Linear(256, 1)
185
+ )
186
+
187
+ def forward(self, images, input_ids, attention_mask):
188
+ img_features = self.img_encoder(images)
189
+ img_emb = self.img_proj(img_features)
190
+ txt_outputs = self.txt_encoder(input_ids=input_ids, attention_mask=attention_mask)
191
+ txt_emb = txt_outputs.last_hidden_state.mean(dim=1)
192
+ txt_emb = self.txt_proj(txt_emb)
193
+ combined = torch.cat([img_emb, txt_emb], dim=1)
194
+ return self.reward_head(combined).squeeze(-1)
195
+
196
+
197
+ # ─────────────────────────────────────────────────────────────────────────────
198
+ # MODEL LOADER β€” handles both .pt (state_dict) and .pkl (full model)
199
+ # Prints a key-match diagnostic so you can see exactly what loaded.
200
+ # ─────────────────────────────────────────────────────────────────────────────
201
+ def remap_keys(raw_sd: dict, label: str) -> dict:
202
+ """
203
+ Remap state_dict keys to match current model attribute names.
204
+
205
+ Known mismatches discovered from diagnostic output:
206
+ SFT notebook used:
207
+ img_encoder.encoder.* β†’ we use img_encoder.backbone.*
208
+ t5.* β†’ we use t5.* (already correct for SFTVisionT5Model)
209
+ proj.* β†’ we use proj.* (already correct for SFTVisionT5Model)
210
+ PPO/RM notebooks used:
211
+ img_encoder.backbone.* β†’ already correct βœ…
212
+ txt_model.* β†’ already correct βœ…
213
+ img_proj.* β†’ already correct βœ…
214
+ """
215
+ remapped = {}
216
+ changed = 0
217
+ for k, v in raw_sd.items():
218
+ new_k = k
219
+ # SFT encoder used self.encoder, our CoAtNetEncoder uses self.backbone
220
+ if "img_encoder.encoder." in new_k:
221
+ new_k = new_k.replace("img_encoder.encoder.", "img_encoder.backbone.")
222
+ changed += 1
223
+ remapped[new_k] = v
224
+ if changed:
225
+ print(f" πŸ”§ Remapped {changed} keys: img_encoder.encoder.* β†’ img_encoder.backbone.*")
226
+ return remapped
227
+
228
+
229
+ def load_model(path: str, model_obj: nn.Module, label: str) -> nn.Module:
230
+ print(f"\nπŸ“‚ Loading {label} from: {path}")
231
+
232
+ if path.endswith(".pkl"):
233
+ with open(path, "rb") as f:
234
+ loaded = pickle.load(f)
235
+ print(f" βœ… Loaded full pickle object: {type(loaded)}")
236
+ return loaded.to(device)
237
+
238
+ # .pt state_dict
239
+ raw_sd = torch.load(path, map_location=device)
240
+
241
+ # Print first 5 saved keys for diagnosis
242
+ saved_keys = list(raw_sd.keys())
243
+ print(f" Saved keys (first 5): {saved_keys[:5]}")
244
+ model_keys = list(model_obj.state_dict().keys())
245
+ print(f" Model keys (first 5): {model_keys[:5]}")
246
+
247
+ # Remap any mismatched key prefixes
248
+ raw_sd = remap_keys(raw_sd, label)
249
+
250
+ result = model_obj.load_state_dict(raw_sd, strict=False)
251
+
252
+ # Ignore known-safe missing keys:
253
+ # head.fc.* - classification head, intentionally removed (num_classes=0)
254
+ # num_batches_tracked - BatchNorm counter, not a learned weight
255
+ SAFE_MISSING = ("num_batches_tracked", "head.fc.")
256
+ missing = [k for k in result.missing_keys if not any(s in k for s in SAFE_MISSING)]
257
+ unexpected = [k for k in result.unexpected_keys if "num_batches_tracked" not in k]
258
+
259
+ if missing:
260
+ print(f" Missing keys: {missing[:5]}{'...' if len(missing)>5 else ''}")
261
+ print(f" WARNING: {len(missing)} missing keys - weights NOT loaded for those layers!")
262
+ if unexpected:
263
+ print(f" Unexpected keys: {unexpected[:5]}{'...' if len(unexpected)>5 else ''}")
264
+ if not missing and not unexpected:
265
+ print(f" OK: All keys matched perfectly!")
266
+
267
+ return model_obj.to(device)
268
+
269
+
270
+ # ─────────────────────────────────────────────────────────────────────────────
271
+ # LOAD ALL THREE MODELS FROM HUGGING FACE HUB
272
+ # Models are downloaded from Shree2604/BioStack repository
273
+ # ─────────────────────────────────────────────────────────────────────────────
274
+ def download_model_from_hf(model_filename: str, local_path: str = "models/") -> str:
275
+ """Download model from Hugging Face Hub if not exists locally"""
276
+ os.makedirs(local_path, exist_ok=True)
277
+ full_path = os.path.join(local_path, model_filename)
278
+
279
+ if not os.path.exists(full_path):
280
+ print(f" Downloading {model_filename} from Hugging Face Hub...")
281
+ try:
282
+ downloaded_path = hf_hub_download(
283
+ repo_id="Shree2604/BioStack",
284
+ filename=model_filename,
285
+ local_dir=local_path,
286
+ local_dir_use_symlinks=False
287
+ )
288
+ print(f" Downloaded {model_filename}")
289
+ return downloaded_path
290
+ except Exception as e:
291
+ print(f" Failed to download {model_filename}: {e}")
292
+ raise
293
+ else:
294
+ print(f" Using local {model_filename}")
295
+ return full_path
296
+
297
+ print("\n" + "="*60)
298
+ print(" LOADING MODELS FROM HUGGING FACE HUB")
299
+ print("="*60)
300
+
301
+ # Download models from Hugging Face
302
+ SFT_MODEL_PATH = download_model_from_hf("best_model.pt")
303
+ REWARD_MODEL_PATH = download_model_from_hf("reward_model.pt")
304
+ PPO_MODEL_PATH = download_model_from_hf("rlhf_model.pt")
305
+
306
+ # SFT
307
+ _sft_enc = CoAtNetEncoder(pretrained=False)
308
+ sft_model = load_model(SFT_MODEL_PATH, SFTVisionT5Model(_sft_enc), "SFT Model")
309
+ sft_model.eval()
310
+
311
+ # Reward
312
+ _rm_enc = CoAtNetEncoder(pretrained=False)
313
+ reward_model = load_model(REWARD_MODEL_PATH, RewardModel(_rm_enc), "Reward Model")
314
+ reward_model.eval()
315
+
316
+ # PPO
317
+ _ppo_enc = CoAtNetEncoder(pretrained=False)
318
+ ppo_model = load_model(PPO_MODEL_PATH, PPOVisionT5Model(_ppo_enc), "PPO Model")
319
+ ppo_model.eval()
320
+
321
+ print("\n All models loaded and ready!\n" + "="*60 + "\n")
322
+
323
+
324
+ # ─────────────────────────────────────────────────────────────────────────────
325
+ # IMAGE PREPROCESSING
326
+ # Matches BOTH notebooks: RGB, 224Γ—224, ImageNet normalisation
327
+ # ─────────────────────────────────────────────────────────────────────────────
328
+ transform = transforms.Compose([
329
+ transforms.Resize((224, 224)),
330
+ transforms.ToTensor(),
331
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
332
+ std=[0.229, 0.224, 0.225])
333
+ ])
334
+
335
+ def preprocess(file_bytes: bytes) -> torch.Tensor:
336
+ img = Image.open(io.BytesIO(file_bytes)).convert("RGB")
337
+ return transform(img).unsqueeze(0).to(device) # [1, 3, 224, 224]
338
+
339
+
340
+ # ─────────────────────────────────────────────────────────────────────────────
341
+ # REWARD FEEDBACK GENERATOR
342
+ # ─────────────────────────────────────────────────────────────────────────────
343
+ KEY_MEDICAL_TERMS = [
344
+ 'lung', 'heart', 'normal', 'clear', 'opacity', 'infiltrate',
345
+ 'cardiomegaly', 'pleural', 'pulmonary', 'chest', 'thorax',
346
+ 'pneumonia', 'edema', 'effusion', 'consolidation'
347
+ ]
348
+
349
+ def reward_feedback(report: str, score: float) -> str:
350
+ rl = report.lower()
351
+ present = [t for t in KEY_MEDICAL_TERMS if t in rl]
352
+ missing = [t for t in KEY_MEDICAL_TERMS if t not in rl]
353
+ words = len(report.split())
354
+ length_q = "good" if 50 <= words <= 150 else ("too short" if words < 50 else "too long")
355
+
356
+ # Quality factor assessments based on the score and analysis
357
+ terminology_score = len(present) / len(KEY_MEDICAL_TERMS)
358
+ completeness_score = min(1.0, words / 100.0) # Rough estimate based on length
359
+ structure_score = 1.0 if 50 <= words <= 150 else 0.5 # Good structure if proper length
360
+ radiological_score = score # The overall score represents alignment
361
+
362
+ return (
363
+ f"Reward Score: {score:.2f} | "
364
+ f"Quality Factors - "
365
+ f"Medical Terminology: {terminology_score:.1%} | "
366
+ f"Clinical Completeness: {completeness_score:.1%} | "
367
+ f"Report Structure: {structure_score:.1%}"
368
+ )
369
+
370
+
371
+ # ─────────────────────────────────────────────────────────────────────────────
372
+ # FASTAPI APP
373
+ # ─────────────────────────────────────────────────────────────────────────────
374
+ app = FastAPI(title="RLHF Medical Demo")
375
+
376
+ app.add_middleware(
377
+ CORSMiddleware,
378
+ allow_origins=["*"],
379
+ allow_methods=["*"],
380
+ allow_headers=["*"],
381
+ )
382
+
383
+
384
+ @app.get("/health")
385
+ def health():
386
+ return {"status": "ok", "device": str(device)}
387
+
388
+
389
+ @app.post("/sft")
390
+ async def sft_inference(file: UploadFile = File(...)):
391
+ try:
392
+ tensor = preprocess(await file.read())
393
+ report = sft_model.generate_reports(tensor)[0]
394
+ print(f"[SFT] Generated: {report}")
395
+ return {"report": report[:81]}
396
+ except Exception as e:
397
+ traceback.print_exc()
398
+ return {"report": f"ERROR: {str(e)}"}
399
+
400
+
401
+ @app.post("/reward")
402
+ async def reward_inference(file: UploadFile = File(...)):
403
+ try:
404
+ tensor = preprocess(await file.read())
405
+
406
+ # First get the SFT report to score
407
+ sft_report = sft_model.generate_reports(tensor)[0]
408
+ print(f"[REWARD] Scoring SFT report: {sft_report}")
409
+
410
+ if not sft_report.strip():
411
+ return {"score": 0.0, "feedback": "", "sft_report": ""}
412
+
413
+ enc = tokenizer(
414
+ [sft_report],
415
+ max_length=128,
416
+ padding="max_length",
417
+ truncation=True,
418
+ return_tensors="pt"
419
+ )
420
+ input_ids = enc.input_ids.to(device)
421
+ attention_mask = enc.attention_mask.to(device)
422
+
423
+ with torch.no_grad():
424
+ raw_score = reward_model(tensor, input_ids, attention_mask).item()
425
+
426
+ # Detailed debug logging
427
+ print(f"[REWARD] Raw neural network output: {raw_score:.6f}")
428
+ print(f"[REWARD] Clamping to [0,1] range: max(0.0, min(1.0, {raw_score:.6f})) = {max(0.0, min(1.0, raw_score)):.6f}")
429
+
430
+ # Quality assessment details
431
+ rl = sft_report.lower()
432
+ present = [t for t in KEY_MEDICAL_TERMS if t in rl]
433
+ missing = [t for t in KEY_MEDICAL_TERMS if t not in rl]
434
+ words = len(sft_report.split())
435
+ length_q = "good" if 50 <= words <= 150 else ("too short" if words < 50 else "too long")
436
+
437
+ print(f"[REWARD] Report analysis:")
438
+ print(f" - Total words: {words} ({length_q})")
439
+ print(f" - Medical terms present ({len(present)}/{len(KEY_MEDICAL_TERMS)}): {present}")
440
+ print(f" - Medical terms missing: {missing}")
441
+ print(f" - Key terms list: {KEY_MEDICAL_TERMS}")
442
+
443
+ # Reward model architecture details
444
+ print(f"[REWARD] Model architecture:")
445
+ print(f" - CoAtNet feature dim: {reward_model.img_encoder.feature_dim}")
446
+ print(f" - T5 d_model: {reward_model.txt_encoder.config.d_model}")
447
+ print(f" - Combined feature dim: 1024 (512 img + 512 text)")
448
+ print(f" - Reward head: 1024β†’512β†’256β†’1")
449
+
450
+ # Clamped score for display
451
+ score = float(max(0.0, min(1.0, raw_score)))
452
+ feedback = reward_feedback(sft_report, score)
453
+ print(f"[REWARD] Final Score={score:.3f}")
454
+ return {"score": score, "feedback": feedback, "sft_report": sft_report}
455
+
456
+ except Exception as e:
457
+ traceback.print_exc()
458
+ return {"score": 0.0, "feedback": f"ERROR: {str(e)}", "sft_report": ""}
459
+
460
+
461
+ @app.post("/ppo")
462
+ async def ppo_inference(file: UploadFile = File(...)):
463
+ try:
464
+ tensor = preprocess(await file.read())
465
+ report = ppo_model.generate_reports(tensor)[0]
466
+ print(f"[PPO] Generated: {report}")
467
+ return {"report": report}
468
+ except Exception as e:
469
+ traceback.print_exc()
470
+ return {"report": f"ERROR: {str(e)}"}
471
+
472
+
473
+ # ─────────────────────────────────────────────────────────────────────────────
474
+ # DIAGNOSTIC ENDPOINT β€” call GET /debug_keys to verify key names in your files
475
+ # e.g. curl http://localhost:8000/debug_keys
476
+ # ─────────────────────────────────────────────────────────────────────────────
477
+ @app.get("/debug_keys")
478
+ def debug_keys():
479
+ import os
480
+ result = {}
481
+ for label, path in [("SFT", SFT_MODEL_PATH), ("Reward", REWARD_MODEL_PATH), ("PPO", PPO_MODEL_PATH)]:
482
+ if not os.path.exists(path):
483
+ result[label] = f"FILE NOT FOUND: {path}"
484
+ continue
485
+ try:
486
+ sd = torch.load(path, map_location="cpu")
487
+ keys = list(sd.keys())
488
+ result[label] = {"first_10_keys": keys[:10], "total_keys": len(keys)}
489
+ except Exception as e:
490
+ result[label] = f"ERROR: {e}"
491
+ return result
492
+
493
+
494
+ if __name__ == "__main__":
495
+ import uvicorn
496
+ uvicorn.run(app, host="0.0.0.0", port=7860, reload=False)
src/App.css ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .App {
2
+ text-align: center;
3
+ }
4
+
5
+ .App-logo {
6
+ height: 40vmin;
7
+ pointer-events: none;
8
+ }
9
+
10
+ @media (prefers-reduced-motion: no-preference) {
11
+ .App-logo {
12
+ animation: App-logo-spin infinite 20s linear;
13
+ }
14
+ }
15
+
16
+ .App-header {
17
+ background-color: #282c34;
18
+ min-height: 100vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ justify-content: center;
23
+ font-size: calc(10px + 2vmin);
24
+ color: white;
25
+ }
26
+
27
+ .App-link {
28
+ color: #61dafb;
29
+ }
30
+
31
+ @keyframes App-logo-spin {
32
+ from {
33
+ transform: rotate(0deg);
34
+ }
35
+ to {
36
+ transform: rotate(360deg);
37
+ }
38
+ }
src/App.js ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback } from "react";
2
+
3
+ // ─────────────────────────────────────────────────────────
4
+ // THEME DEFINITIONS
5
+ // ─────────────────────────────────────────────────────────
6
+ const THEMES = {
7
+ dark: {
8
+ name: "dark",
9
+ bg: "#070d14",
10
+ surface: "#0f1923",
11
+ surfaceAlt: "#0a1520",
12
+ border: "#1e2d3d",
13
+ borderFocus: "#3b82f6",
14
+ text: "#e2e8f0",
15
+ textMuted: "#4a6080",
16
+ textSub: "#94a3b8",
17
+ textBody: "#cbd5e1",
18
+ inputBg: "#0a1520",
19
+ uploadBg: "#0a1520",
20
+ uploadHover: "#1e2d3d",
21
+ scrollTrack: "#0f1923",
22
+ scrollThumb: "#1e3a5f",
23
+ infoBg: "#0a1520",
24
+ emptyColor: "#1e3a5f",
25
+ cardBg: "#0f1923",
26
+ barTrack: "#1e2d3d",
27
+ btnDisabled: "#1e2d3d",
28
+ btnDisabledTxt:"#4a6080",
29
+ headerShadow: "none",
30
+ cardShadow: "none",
31
+ },
32
+ light: {
33
+ name: "light",
34
+ bg: "#f0f4f8",
35
+ surface: "#ffffff",
36
+ surfaceAlt: "#f8fafc",
37
+ border: "#d1dde9",
38
+ borderFocus: "#2563eb",
39
+ text: "#0f172a",
40
+ textMuted: "#64748b",
41
+ textSub: "#475569",
42
+ textBody: "#334155",
43
+ inputBg: "#ffffff",
44
+ uploadBg: "#f8fafc",
45
+ uploadHover: "#e2eaf4",
46
+ scrollTrack: "#e2e8f0",
47
+ scrollThumb: "#94a3b8",
48
+ infoBg: "#f1f5f9",
49
+ emptyColor: "#94a3b8",
50
+ cardBg: "#ffffff",
51
+ barTrack: "#e2e8f0",
52
+ btnDisabled: "#e2e8f0",
53
+ btnDisabledTxt:"#94a3b8",
54
+ headerShadow: "0 1px 6px rgba(0,0,0,0.07)",
55
+ cardShadow: "0 1px 4px rgba(0,0,0,0.06)",
56
+ },
57
+ };
58
+
59
+ // ─────────────────────────────────────────────────────────
60
+ // ROUGE-L CALCULATION
61
+ // ─────────────────────────────────────────────────────────
62
+ const ROUGE_L = (hyp, ref) => {
63
+ if (!hyp || !ref) return 0;
64
+ const hypW = hyp.toLowerCase().split(/\s+/);
65
+ const refW = ref.toLowerCase().split(/\s+/);
66
+ const m = hypW.length, n = refW.length;
67
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
68
+ for (let i = 1; i <= m; i++)
69
+ for (let j = 1; j <= n; j++)
70
+ dp[i][j] = hypW[i-1] === refW[j-1]
71
+ ? dp[i-1][j-1] + 1
72
+ : Math.max(dp[i-1][j], dp[i][j-1]);
73
+ const lcs = dp[m][n];
74
+ const prec = lcs / m, rec = lcs / n;
75
+ if (prec + rec === 0) return 0;
76
+ return ((2 * prec * rec) / (prec + rec)).toFixed(4);
77
+ };
78
+
79
+ // ─────────────────────────────────────────────────────────
80
+ // THEME TOGGLE
81
+ // ─────────────────────────────────────────────────────────
82
+ const ThemeToggle = ({ theme, onToggle }) => {
83
+ const isDark = theme.name === "dark";
84
+ return (
85
+ <button
86
+ onClick={onToggle}
87
+ title={`Switch to ${isDark ? "light" : "dark"} mode`}
88
+ style={{
89
+ display: "flex", alignItems: "center", gap: 9,
90
+ background: isDark ? "#1a2d42" : "#e8f0fa",
91
+ border: `1px solid ${isDark ? "#2d4a6a" : "#c4d4e8"}`,
92
+ borderRadius: 24, padding: "6px 14px 6px 8px",
93
+ cursor: "pointer", color: theme.text,
94
+ fontSize: 12, fontWeight: 600, letterSpacing: 0.4,
95
+ transition: "background .3s, border-color .3s",
96
+ }}
97
+ >
98
+ {/* Toggle track */}
99
+ <div style={{
100
+ width: 42, height: 23, borderRadius: 12,
101
+ background: isDark ? "#1d4ed8" : "#f59e0b",
102
+ position: "relative", flexShrink: 0,
103
+ transition: "background .35s ease",
104
+ }}>
105
+ {/* Thumb */}
106
+ <div style={{
107
+ position: "absolute",
108
+ top: "50%", transform: "translateY(-50%)",
109
+ left: isDark ? 22 : 3,
110
+ width: 17, height: 17, borderRadius: "50%",
111
+ background: "#fff",
112
+ boxShadow: "0 1px 5px rgba(0,0,0,0.25)",
113
+ display: "grid", placeItems: "center",
114
+ fontSize: 9,
115
+ transition: "left .28s ease",
116
+ }}>
117
+ {isDark ? "πŸŒ™" : "β˜€οΈ"}
118
+ </div>
119
+ </div>
120
+ <span style={{ color: theme.textMuted, userSelect: "none" }}>
121
+ {isDark ? "Dark" : "Light"}
122
+ </span>
123
+ </button>
124
+ );
125
+ };
126
+
127
+ // ─────────────────────────────────────────────────────────
128
+ // SCORE BADGE
129
+ // ─────────────────────────────────────────────────────────
130
+ const ScoreBadge = ({ score }) => {
131
+ const pct = parseFloat(score) * 100;
132
+ const color = pct >= 60 ? "#22c55e" : pct >= 35 ? "#f59e0b" : "#ef4444";
133
+ return (
134
+ <span style={{
135
+ background: color + "22", color,
136
+ border: `1px solid ${color}55`,
137
+ borderRadius: 6, padding: "2px 10px",
138
+ fontFamily: "monospace", fontWeight: 700, fontSize: 13,
139
+ }}>
140
+ ROUGE-L: {pct.toFixed(1)}%
141
+ </span>
142
+ );
143
+ };
144
+
145
+ // ─────────────────────────────────────────────────────────
146
+ // OUTPUT CARD
147
+ // ─────────────────────────────────────────────────────────
148
+ const OutputCard = ({ title, icon, content, badge, accent, loading, theme }) => (
149
+ <div style={{
150
+ background: theme.cardBg,
151
+ border: `1px solid ${accent}33`,
152
+ borderRadius: 14, padding: 20,
153
+ position: "relative", overflow: "hidden",
154
+ boxShadow: theme.cardShadow,
155
+ transition: "background .3s, box-shadow .3s",
156
+ }}>
157
+ {/* Accent top bar */}
158
+ <div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 3, background: accent }} />
159
+
160
+ {/* Card header */}
161
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
162
+ <span style={{ fontSize: 18 }}>{icon}</span>
163
+ <span style={{
164
+ fontFamily: "'Courier New', monospace",
165
+ fontSize: 11, fontWeight: 700, color: accent,
166
+ textTransform: "uppercase", letterSpacing: 2,
167
+ }}>{title}</span>
168
+ {badge && <div style={{ marginLeft: "auto" }}>{badge}</div>}
169
+ </div>
170
+
171
+ {/* Body */}
172
+ {loading ? (
173
+ <div style={{ display: "flex", gap: 6, alignItems: "center", color: theme.textMuted, fontSize: 13 }}>
174
+ {[0, 0.2, 0.4].map((d, i) => (
175
+ <span key={i} style={{ animation: `pulse 1s infinite ${d}s` }}>●</span>
176
+ ))}
177
+ <span style={{ marginLeft: 8 }}>Generating…</span>
178
+ </div>
179
+ ) : content ? (
180
+ <p style={{ margin: 0, fontSize: 14, lineHeight: 1.78, color: theme.textBody, fontFamily: "Georgia, serif" }}>
181
+ {content}
182
+ </p>
183
+ ) : (
184
+ <p style={{ margin: 0, fontSize: 13, color: theme.textMuted, fontStyle: "italic" }}>
185
+ Awaiting input…
186
+ </p>
187
+ )}
188
+ </div>
189
+ );
190
+
191
+ // ─────────────────────────────────────────────────────────
192
+ // MAIN APP
193
+ // ─────────────────────────────────────────────────────────
194
+ export default function App() {
195
+ const [themeName, setThemeName] = useState("dark");
196
+ const theme = THEMES[themeName];
197
+ const toggleTheme = () => setThemeName(t => t === "dark" ? "light" : "dark");
198
+
199
+ const [image, setImage] = useState(null);
200
+ const [imageFile, setImageFile] = useState(null);
201
+ const [groundTruth, setGroundTruth] = useState("");
202
+ const [sftOutput, setSftOutput] = useState("");
203
+ const [rewardOutput, setRewardOutput] = useState("");
204
+ const [ppoOutput, setPpoOutput] = useState("");
205
+ const [rewardScore, setRewardScore] = useState(null);
206
+ const [loading, setLoading] = useState(false);
207
+ const [dragging, setDragging] = useState(false);
208
+ const fileRef = useRef();
209
+
210
+ const runInference = async () => {
211
+ if (!image || !imageFile) return;
212
+ setLoading(true);
213
+ setSftOutput(""); setRewardOutput(""); setPpoOutput(""); setRewardScore(null);
214
+
215
+ const BASE = "http://localhost:8000";
216
+
217
+ try {
218
+ // 1. SFT
219
+ const sftForm = new FormData();
220
+ sftForm.append("file", imageFile);
221
+ const sftRes = await fetch(`${BASE}/sft`, { method: "POST", body: sftForm });
222
+ const sftData = await sftRes.json();
223
+ setSftOutput(sftData.report);
224
+
225
+ // 2. Reward
226
+ const rmForm = new FormData();
227
+ rmForm.append("file", imageFile);
228
+ const rmRes = await fetch(`${BASE}/reward`, { method: "POST", body: rmForm });
229
+ const rmData = await rmRes.json();
230
+ setRewardOutput(rmData.feedback);
231
+ setRewardScore(rmData.score.toFixed(2));
232
+
233
+ // 3. PPO
234
+ const ppoForm = new FormData();
235
+ ppoForm.append("file", imageFile);
236
+ const ppoRes = await fetch(`${BASE}/ppo`, { method: "POST", body: ppoForm });
237
+ const ppoData = await ppoRes.json();
238
+ setPpoOutput(ppoData.report);
239
+
240
+ } catch (err) {
241
+ console.error("Inference error:", err);
242
+ setSftOutput("⚠️ Could not connect to server. Make sure server.py is running on port 8000.");
243
+ }
244
+
245
+ setLoading(false);
246
+ };
247
+
248
+ const handleFile = (file) => {
249
+ if (!file || !file.type.startsWith("image/")) return;
250
+ setImageFile(file);
251
+ const reader = new FileReader();
252
+ reader.onload = e => setImage(e.target.result);
253
+ reader.readAsDataURL(file);
254
+ };
255
+
256
+ const onDrop = useCallback((e) => {
257
+ e.preventDefault(); setDragging(false);
258
+ handleFile(e.dataTransfer.files[0]);
259
+ }, []);
260
+
261
+ const clearAll = () => {
262
+ setImage(null); setImageFile(null);
263
+ setSftOutput(""); setRewardOutput(""); setPpoOutput(""); setRewardScore(null);
264
+ if (fileRef.current) fileRef.current.value = "";
265
+ };
266
+
267
+ const rougeSFT = groundTruth && sftOutput ? ROUGE_L(sftOutput, groundTruth) : null;
268
+ const rougePPO = groundTruth && ppoOutput ? ROUGE_L(ppoOutput, groundTruth) : null;
269
+
270
+ return (
271
+ <div style={{
272
+ minHeight: "100vh",
273
+ background: theme.bg,
274
+ color: theme.text,
275
+ fontFamily: "system-ui, -apple-system, sans-serif",
276
+ transition: "background .3s ease, color .3s ease",
277
+ }}>
278
+
279
+ {/* ── GLOBAL STYLES ── */}
280
+ <style>{`
281
+ @keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
282
+ @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
283
+ * { box-sizing: border-box; margin: 0; padding: 0; }
284
+ ::-webkit-scrollbar { width: 6px; }
285
+ ::-webkit-scrollbar-track { background: ${theme.scrollTrack}; }
286
+ ::-webkit-scrollbar-thumb { background: ${theme.scrollThumb}; border-radius: 3px; }
287
+ textarea { font-family: system-ui, sans-serif; }
288
+ textarea:focus {
289
+ outline: none;
290
+ border-color: ${theme.borderFocus} !important;
291
+ box-shadow: 0 0 0 3px ${theme.borderFocus}22;
292
+ }
293
+ button { transition: all .22s ease !important; }
294
+ button:not(:disabled):hover { filter: brightness(1.1); transform: translateY(-1px); }
295
+ button:not(:disabled):active { transform: translateY(0px); filter: brightness(0.97); }
296
+ `}</style>
297
+
298
+ {/* ═══════════════════════════════════
299
+ HEADER
300
+ ═══════════════════════════════════ */}
301
+ <header style={{
302
+ borderBottom: `2px solid ${theme.border}`,
303
+ padding: "20px 32px",
304
+ display: "flex", alignItems: "center", gap: 18,
305
+ background: `linear-gradient(135deg, ${theme.surface} 0%, ${theme.surfaceAlt} 100%)`,
306
+ boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
307
+ transition: "background .3s, border-color .3s, box-shadow .3s",
308
+ position: "sticky", top: 0, zIndex: 100,
309
+ backdropFilter: "blur(10px)",
310
+ WebkitBackdropFilter: "blur(10px)",
311
+ }}>
312
+ {/* Logo mark */}
313
+ <div style={{
314
+ width: 52, height: 52, flexShrink: 0,
315
+ background: "linear-gradient(135deg,#3b82f6,#06b6d4)",
316
+ borderRadius: 14, display: "grid", placeItems: "center",
317
+ fontSize: 24, boxShadow: "0 4px 16px rgba(59,130,246,0.4)",
318
+ border: "2px solid rgba(255,255,255,0.2)",
319
+ }}>🫁</div>
320
+
321
+ {/* Titles */}
322
+ <div>
323
+ <div style={{
324
+ fontWeight: 900,
325
+ fontSize: 22,
326
+ letterSpacing: -0.8,
327
+ color: theme.text,
328
+ textShadow: theme.name === "dark" ? "0 1px 2px rgba(0,0,0,0.3)" : "none",
329
+ marginBottom: 4
330
+ }}>
331
+ BioStack
332
+ </div>
333
+ <div style={{
334
+ fontSize: 12,
335
+ color: theme.textMuted,
336
+ letterSpacing: 1.2,
337
+ textTransform: "uppercase",
338
+ fontWeight: 600,
339
+ opacity: 0.9
340
+ }}>
341
+ RLHF Based Medical Report Generation
342
+ </div>
343
+ </div>
344
+
345
+ <div style={{ flex: 1 }} />
346
+
347
+ {/* πŸŒ™/β˜€οΈ Toggle */}
348
+ <ThemeToggle theme={theme} onToggle={toggleTheme} />
349
+ </header>
350
+
351
+ {/* ═══════════════════════════════════
352
+ MAIN GRID
353
+ ═══════════════════════════════════ */}
354
+ <div style={{
355
+ display: "grid",
356
+ gridTemplateColumns: "370px 1fr",
357
+ height: "calc(100vh - 155px)", // Further decreased for optimal fit
358
+ overflow: "hidden", // Prevent any overflow
359
+ }}>
360
+
361
+ {/* ══ LEFT PANEL ══ */}
362
+ <aside style={{
363
+ borderRight: `1px solid ${theme.border}`,
364
+ padding: "15px 18px", // Reduced padding
365
+ display: "flex", flexDirection: "column", gap: 14, // Reduced gap
366
+ background: theme.surface,
367
+ overflowY: "auto",
368
+ transition: "background .3s, border-color .3s",
369
+ height: "100%", // Ensure full height
370
+ }}>
371
+
372
+ <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700 }}>
373
+ πŸ“€ Input
374
+ </div>
375
+
376
+ {/* Upload zone */}
377
+ <div>
378
+ <label style={{
379
+ fontSize: 11, color: theme.textMuted,
380
+ textTransform: "uppercase", letterSpacing: 1.5,
381
+ fontWeight: 700, display: "block", marginBottom: 8,
382
+ }}>Chest X-Ray Image</label>
383
+
384
+ <div
385
+ onClick={() => fileRef.current.click()}
386
+ onDragOver={e => { e.preventDefault(); setDragging(true); }}
387
+ onDragLeave={() => setDragging(false)}
388
+ onDrop={onDrop}
389
+ style={{
390
+ border: `2px dashed ${dragging ? "#3b82f6" : image ? "#22c55e66" : theme.border}`,
391
+ borderRadius: 14, minHeight: 190,
392
+ display: "flex", flexDirection: "column",
393
+ alignItems: "center", justifyContent: "center",
394
+ cursor: "pointer",
395
+ background: dragging ? theme.uploadHover : theme.uploadBg,
396
+ transition: "all .22s", overflow: "hidden",
397
+ }}
398
+ >
399
+ {image ? (
400
+ <img src={image} alt="X-Ray" style={{
401
+ width: "100%", height: "100%",
402
+ objectFit: "contain", maxHeight: 240, borderRadius: 12,
403
+ }} />
404
+ ) : (
405
+ <>
406
+ <div style={{ fontSize: 40, marginBottom: 10, opacity: 0.65 }}>🩻</div>
407
+ <div style={{ fontSize: 13, color: theme.textMuted, textAlign: "center", lineHeight: 1.65 }}>
408
+ Click or drag & drop<br />
409
+ <span style={{ fontSize: 11, color: theme.emptyColor }}>PNG, JPG, DICOM supported</span>
410
+ </div>
411
+ </>
412
+ )}
413
+ </div>
414
+ <input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }}
415
+ onChange={e => handleFile(e.target.files[0])} />
416
+ </div>
417
+
418
+ {/* Clear button */}
419
+ {image && (
420
+ <button onClick={clearAll} style={{
421
+ background: "transparent",
422
+ border: `1px solid ${theme.border}`,
423
+ color: theme.textMuted,
424
+ borderRadius: 8, padding: "5px 14px",
425
+ cursor: "pointer", fontSize: 12, alignSelf: "flex-start",
426
+ }}>
427
+ βœ• Clear Image
428
+ </button>
429
+ )}
430
+
431
+ {/* Divider */}
432
+ <div style={{ height: 1, background: theme.border }} />
433
+
434
+ {/* Ground truth */}
435
+ <div>
436
+ <label style={{
437
+ fontSize: 11, color: theme.textMuted,
438
+ textTransform: "uppercase", letterSpacing: 1.5,
439
+ fontWeight: 700, display: "block", marginBottom: 8,
440
+ }}>
441
+ πŸ“‹ Ground Truth{" "}
442
+ <span style={{ color: theme.emptyColor, textTransform: "none", letterSpacing: 0, fontWeight: 400, fontSize: 11 }}>
443
+ (optional)
444
+ </span>
445
+ </label>
446
+ <textarea
447
+ value={groundTruth}
448
+ onChange={e => setGroundTruth(e.target.value)}
449
+ placeholder="Paste the radiologist ground truth report here to see ROUGE-L scores…"
450
+ rows={5}
451
+ style={{
452
+ width: "100%",
453
+ background: theme.inputBg,
454
+ border: `1px solid ${theme.border}`,
455
+ borderRadius: 10,
456
+ color: theme.text,
457
+ padding: "10px 12px",
458
+ fontSize: 13, resize: "vertical", lineHeight: 1.65,
459
+ transition: "border .2s, box-shadow .2s, background .3s, color .3s",
460
+ }}
461
+ />
462
+ </div>
463
+
464
+ {/* Run button */}
465
+ <button
466
+ onClick={runInference}
467
+ disabled={!image || loading}
468
+ style={{
469
+ background: image && !loading
470
+ ? "linear-gradient(135deg,#1d4ed8,#0891b2)"
471
+ : theme.btnDisabled,
472
+ color: image && !loading ? "#fff" : theme.btnDisabledTxt,
473
+ border: "none", borderRadius: 12,
474
+ padding: "13px 20px",
475
+ fontSize: 14, fontWeight: 700,
476
+ cursor: image && !loading ? "pointer" : "not-allowed",
477
+ letterSpacing: 0.5, width: "100%",
478
+ boxShadow: image && !loading ? "0 4px 16px rgba(29,78,216,0.38)" : "none",
479
+ }}>
480
+ {loading ? "⏳ Running Pipeline…" : "β–Ά Run RLHF Pipeline"}
481
+ </button>
482
+ </aside>
483
+
484
+ {/* ══ RIGHT PANEL ══ */}
485
+ <main style={{
486
+ padding: "18px 22px", // Reduced padding
487
+ display: "flex", flexDirection: "column", gap: 16, // Reduced gap
488
+ overflowY: "auto",
489
+ background: theme.bg,
490
+ transition: "background .3s",
491
+ height: "100%", // Ensure full height
492
+ }}>
493
+
494
+ <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700 }}>
495
+ πŸ“Š Pipeline Outputs
496
+ </div>
497
+
498
+ {/* SFT */}
499
+ <div style={{ animation: sftOutput ? "fadeIn .4s ease" : "none" }}>
500
+ <OutputCard theme={theme} title="SFT Model Output β€” Original" icon="🧠" accent="#3b82f6"
501
+ content={sftOutput} loading={loading && !sftOutput}
502
+ badge={rougeSFT !== null && <ScoreBadge score={rougeSFT} />}
503
+ />
504
+ </div>
505
+
506
+ {/* Reward */}
507
+ <div style={{ animation: rewardOutput ? "fadeIn .4s ease" : "none" }}>
508
+ <OutputCard theme={theme} title="Reward Model Output" icon="βš–οΈ" accent="#f59e0b"
509
+ content={rewardOutput} loading={loading && sftOutput && !rewardOutput}
510
+ badge={rewardScore && (
511
+ <span style={{
512
+ background: "#f59e0b22", color: "#f59e0b",
513
+ border: "1px solid #f59e0b55",
514
+ borderRadius: 6, padding: "2px 10px",
515
+ fontFamily: "monospace", fontWeight: 700, fontSize: 13,
516
+ }}>
517
+ Reward: {rewardScore}
518
+ </span>
519
+ )}
520
+ />
521
+ </div>
522
+
523
+ {/* PPO */}
524
+ <div style={{ animation: ppoOutput ? "fadeIn .4s ease" : "none" }}>
525
+ <OutputCard theme={theme} title="PPO Final Model Output" icon="🎯" accent="#22c55e"
526
+ content={ppoOutput} loading={loading && rewardOutput && !ppoOutput}
527
+ badge={rougePPO !== null && <ScoreBadge score={rougePPO} />}
528
+ />
529
+ </div>
530
+
531
+ {/* ROUGE-L comparison */}
532
+ {groundTruth && sftOutput && ppoOutput && (
533
+ <div style={{
534
+ background: theme.surface,
535
+ border: `1px solid ${theme.border}`,
536
+ borderRadius: 14, padding: 20,
537
+ animation: "fadeIn .5s ease",
538
+ boxShadow: theme.cardShadow,
539
+ transition: "background .3s, border-color .3s",
540
+ }}>
541
+ <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700, marginBottom: 16 }}>
542
+ πŸ“ˆ ROUGE-L Comparison vs Ground Truth
543
+ </div>
544
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
545
+ {[
546
+ { label: "SFT (Original)", score: rougeSFT, color: "#3b82f6" },
547
+ { label: "PPO (Final)", score: rougePPO, color: "#22c55e" },
548
+ ].map(({ label, score, color }) => (
549
+ <div key={label} style={{
550
+ background: theme.surfaceAlt, borderRadius: 10, padding: 16,
551
+ border: `1px solid ${color}33`,
552
+ transition: "background .3s",
553
+ }}>
554
+ <div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 8 }}>{label}</div>
555
+ <div style={{ fontSize: 28, fontWeight: 800, color, fontFamily: "monospace" }}>
556
+ {(parseFloat(score) * 100).toFixed(1)}%
557
+ </div>
558
+ <div style={{ marginTop: 10, height: 5, background: theme.barTrack, borderRadius: 3 }}>
559
+ <div style={{
560
+ width: `${parseFloat(score) * 100}%`, height: "100%",
561
+ background: color, borderRadius: 3,
562
+ transition: "width 1.2s ease",
563
+ }} />
564
+ </div>
565
+ {parseFloat(rougePPO) > parseFloat(rougeSFT) && label.includes("PPO") && (
566
+ <div style={{ fontSize: 11, color: "#22c55e", marginTop: 7, fontWeight: 600 }}>
567
+ β–² +{((parseFloat(rougePPO) - parseFloat(rougeSFT)) * 100).toFixed(1)}% improvement
568
+ </div>
569
+ )}
570
+ </div>
571
+ ))}
572
+ </div>
573
+ </div>
574
+ )}
575
+
576
+ {/* Ground truth reference */}
577
+ {groundTruth && (
578
+ <div style={{
579
+ background: theme.surface,
580
+ border: `1px solid ${theme.border}`,
581
+ borderRadius: 14, padding: 20,
582
+ boxShadow: theme.cardShadow,
583
+ transition: "background .3s, border-color .3s",
584
+ }}>
585
+ <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700, marginBottom: 10 }}>
586
+ πŸ“‹ Ground Truth Reference
587
+ </div>
588
+ <p style={{ fontSize: 14, lineHeight: 1.78, color: theme.textSub, fontFamily: "Georgia, serif" }}>
589
+ {groundTruth}
590
+ </p>
591
+ </div>
592
+ )}
593
+
594
+ {/* Empty state */}
595
+ {!image && !loading && (
596
+ <div style={{
597
+ flex: 1, display: "flex", flexDirection: "column",
598
+ alignItems: "center", justifyContent: "center",
599
+ color: theme.emptyColor, gap: 14, paddingTop: 60,
600
+ }}>
601
+ <div style={{ fontSize: 64, opacity: 0.35 }}>🩻</div>
602
+ <div style={{ fontSize: 14, fontWeight: 600, opacity: 0.45 }}>Upload a chest X-ray to begin</div>
603
+ <div style={{ fontSize: 12, opacity: 0.3 }}>Results will appear here after running the pipeline</div>
604
+ </div>
605
+ )}
606
+ </main>
607
+ </div>
608
+
609
+ {/* ═══════════════════════════════════
610
+ FOOTER
611
+ ═══════════════════════════════════ */}
612
+ <footer style={{
613
+ borderTop: `2px solid ${theme.border}`,
614
+ padding: "18px 32px",
615
+ background: `linear-gradient(135deg, ${theme.surfaceAlt} 0%, ${theme.surface} 100%)`,
616
+ textAlign: "center",
617
+ fontSize: 13,
618
+ color: theme.textMuted,
619
+ fontWeight: 600,
620
+ letterSpacing: 0.5,
621
+ transition: "background .3s, border-color .3s, color .3s",
622
+ position: "relative",
623
+ boxShadow: "0 -2px 10px rgba(0,0,0,0.05)",
624
+ backdropFilter: "blur(8px)",
625
+ WebkitBackdropFilter: "blur(8px)",
626
+ }}>
627
+ <div style={{
628
+ display: "flex",
629
+ alignItems: "center",
630
+ justifyContent: "center",
631
+ gap: 8,
632
+ opacity: 0.8
633
+ }}>
634
+ <span style={{ fontSize: 16 }}>Β©</span>
635
+ <span>2026 BioStack. All rights reserved.</span>
636
+
637
+ </div>
638
+ </footer>
639
+ </div>
640
+ );
641
+ }
src/App.test.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from '@testing-library/react';
2
+ import App from './App';
3
+
4
+ test('renders learn react link', () => {
5
+ render(<App />);
6
+ const linkElement = screen.getByText(/learn react/i);
7
+ expect(linkElement).toBeInTheDocument();
8
+ });
src/index.css ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ margin: 0;
3
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5
+ sans-serif;
6
+ -webkit-font-smoothing: antialiased;
7
+ -moz-osx-font-smoothing: grayscale;
8
+ }
9
+
10
+ code {
11
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12
+ monospace;
13
+ }
src/index.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import './index.css';
4
+ import App from './App';
5
+ import reportWebVitals from './reportWebVitals';
6
+
7
+ const root = ReactDOM.createRoot(document.getElementById('root'));
8
+ root.render(
9
+ <React.StrictMode>
10
+ <App />
11
+ </React.StrictMode>
12
+ );
13
+
14
+ // If you want to start measuring performance in your app, pass a function
15
+ // to log results (for example: reportWebVitals(console.log))
16
+ // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17
+ reportWebVitals();
src/logo.svg ADDED
src/reportWebVitals.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const reportWebVitals = onPerfEntry => {
2
+ if (onPerfEntry && onPerfEntry instanceof Function) {
3
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4
+ getCLS(onPerfEntry);
5
+ getFID(onPerfEntry);
6
+ getFCP(onPerfEntry);
7
+ getLCP(onPerfEntry);
8
+ getTTFB(onPerfEntry);
9
+ });
10
+ }
11
+ };
12
+
13
+ export default reportWebVitals;
src/setupTests.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ // jest-dom adds custom jest matchers for asserting on DOM nodes.
2
+ // allows you to do things like:
3
+ // expect(element).toHaveTextContent(/react/i)
4
+ // learn more: https://github.com/testing-library/jest-dom
5
+ import '@testing-library/jest-dom';