gm992 commited on
Commit
20be010
·
verified ·
1 Parent(s): d753d19

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +793 -34
src/streamlit_app.py CHANGED
@@ -1,40 +1,799 @@
1
- import altair as alt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
 
 
 
 
8
 
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ProteinPRO Streamlit App
3
+ Developed 21-22 March 2026 by Gantt Meredith
4
+
5
+ Deployable web interface for polymer-protein hybrid (PPH) formulation prediction and data analysis.
6
+ Automated with Streamlit and deployed to DigitalOcean App Platform.
7
+ Run command: streamlit run app.py
8
+ """
9
+
10
+ import io
11
+ import re
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ # Ensure src is on path
17
+ sys.path.insert(0, str(Path(__file__).parent))
18
+
19
+ # Load API keys from .env
20
+ try:
21
+ from dotenv import load_dotenv
22
+ load_dotenv()
23
+ except ImportError:
24
+ pass
25
+
26
+ # Ensure Auth0 secrets exist (from .env) before Streamlit loads
27
+ try:
28
+ import os
29
+ from pathlib import Path
30
+ _root = Path(__file__).parent
31
+ _domain = os.environ.get("AUTH0_DOMAIN", "").strip()
32
+ _cid = os.environ.get("AUTH0_CLIENT_ID", "").strip()
33
+ _csec = os.environ.get("AUTH0_CLIENT_SECRET", "").strip()
34
+ if _domain and _cid and _csec:
35
+ _streamlit_dir = _root / ".streamlit"
36
+ _streamlit_dir.mkdir(exist_ok=True)
37
+ _secrets = _streamlit_dir / "secrets.toml"
38
+ _redirect = os.environ.get("AUTH0_REDIRECT_URI", "http://localhost:8501/oauth2callback")
39
+ _cookie = os.environ.get("AUTH0_COOKIE_SECRET", "proteinpro-auth-cookie-secret-change-in-production")
40
+ _meta = f"https://{_domain}/.well-known/openid-configuration"
41
+ _block = f'[auth]\nredirect_uri = "{_redirect}"\ncookie_secret = "{_cookie}"\n\n[auth.auth0]\nclient_id = "{_cid}"\nclient_secret = "{_csec}"\nserver_metadata_url = "{_meta}"\n'
42
+ if not _secrets.exists() or _secrets.read_text() != _block:
43
+ _secrets.write_text(_block)
44
+ except Exception:
45
+ pass
46
+
47
  import numpy as np
 
48
  import streamlit as st
49
+ import pandas as pd
50
+ import yaml
51
 
52
+ # 3D viewer
53
+ try:
54
+ import py3Dmol
55
+ HAS_3D = True
56
+ except ImportError:
57
+ HAS_3D = False
58
 
59
+ from src.pdb_handler import (
60
+ fetch_pdb,
61
+ parse_structure,
62
+ featurize_protein,
63
+ get_sequence_and_features,
64
+ get_coordinates_for_visualization,
65
+ get_residue_roles_for_visualization,
66
+ load_config,
67
+ )
68
+ from src.monomer_featurizer import featurize_all_monomers, composition_to_polymer_features, load_monomers
69
+ from src.stability_model import StabilityPredictor, sample_design_space, MODEL_TYPES
70
+ try:
71
+ from src.gpr_predictor import GPRStabilityPredictor
72
+ GPR_AVAILABLE = True
73
+ except ImportError:
74
+ GPRStabilityPredictor = None
75
+ GPR_AVAILABLE = False
76
+ try:
77
+ from src.integrations.gemini_api import ask_formulation_advice
78
+ except ImportError:
79
+ def ask_formulation_advice(*a, **k):
80
+ return "Add GEMINI_API_KEY and install: pip install google-generativeai"
81
+ try:
82
+ from src.integrations.elevenlabs_tts import text_to_speech_audio, is_available as elevenlabs_available
83
+ except ImportError:
84
+ text_to_speech_audio = lambda *a, **k: None
85
+ elevenlabs_available = lambda: False
86
+ try:
87
+ from src.integrations.solana_verify import formulation_hash
88
+ except ImportError:
89
+ formulation_hash = lambda p, c, s: "N/A"
90
+ try:
91
+ from src.integrations.auth0_config import is_available as auth0_available, is_logged_in as auth_is_logged_in, get_user_id as auth_get_user_id
92
+ except ImportError:
93
+ auth0_available = lambda: False
94
+ auth_is_logged_in = lambda: False
95
+ auth_get_user_id = lambda: None
96
+ try:
97
+ from src.stability_data_analysis import (
98
+ read_round_file,
99
+ run_analysis,
100
+ )
101
+ except ImportError:
102
+ read_round_file = None
103
+ run_analysis = None
104
+ try:
105
+ from src.user_pdb_cache import (
106
+ save_fetched_to_user_cache,
107
+ save_upload_to_user_cache,
108
+ list_user_cached,
109
+ load_from_user_cache,
110
+ )
111
+ except ImportError:
112
+ save_fetched_to_user_cache = lambda u, p, s: s
113
+ save_upload_to_user_cache = lambda u, f, b: None
114
+ list_user_cached = lambda u: []
115
+ load_from_user_cache = lambda u, n: None
116
 
117
+ st.set_page_config(page_title="ProteinPRO", page_icon="assets/logo.png", layout="wide")
118
+
119
+ # Global styling for dark theme
120
+ st.markdown("""
121
+ <style>
122
+ /* Softer block container edges */
123
+ .block-container { padding-top: 1.5rem; padding-bottom: 2rem; }
124
+ /* Headers with accent color (light purple on dark) */
125
+ h1, h2, h3 { color: #a78bfa !important; }
126
+ /* Divider styling */
127
+ hr { border-color: rgba(139, 122, 184, 0.3) !important; }
128
+ /* Info boxes */
129
+ .stAlert { border-radius: 8px; }
130
+ /* Metric cards */
131
+ [data-testid="stMetricValue"] { color: #8b7ab8 !important; }
132
+ </style>
133
+ """, unsafe_allow_html=True)
134
+
135
+ LOGO_PATH = Path(__file__).parent / "assets" / "logo.png"
136
+
137
+ # Sidebar config
138
+ if LOGO_PATH.exists():
139
+ _sb_b64 = __import__("base64").b64encode(LOGO_PATH.read_bytes()).decode()
140
+ st.sidebar.markdown(f'<img src="data:image/png;base64,{_sb_b64}" style="width:50px;opacity:0.95;margin-bottom:4px;"/>', unsafe_allow_html=True)
141
+ else:
142
+ st.sidebar.image("assets/logo.png", width=50)
143
+ st.sidebar.divider()
144
+
145
+ # Input mode
146
+ input_options = ["PDB ID", "Upload file"]
147
+ if auth_is_logged_in():
148
+ input_options.insert(0, "From saved")
149
+ input_mode = st.sidebar.radio(
150
+ "Protein input",
151
+ input_options,
152
+ help="Retrieve from RCSB, upload, or load from your saved structures",
153
+ )
154
+
155
+ protein_source = None
156
+ pdb_id = None
157
+
158
+ if input_mode == "From saved" and auth_is_logged_in():
159
+ user_id = auth_get_user_id()
160
+ saved = list_user_cached(user_id)
161
+ if saved:
162
+ chosen = st.sidebar.selectbox("Your saved structures", options=[n for n, _ in saved], format_func=lambda x: x)
163
+ if chosen:
164
+ protein_source = load_from_user_cache(user_id, chosen)
165
+ pdb_id = chosen
166
+ else:
167
+ st.sidebar.info("Hey, there. No saved structures yet. Request or upload a PDB or CIF to save it.")
168
+ elif input_mode == "PDB ID":
169
+ pdb_id = st.sidebar.text_input("PDB ID", value="1LYZ", max_chars=10)
170
+ if pdb_id:
171
+ try:
172
+ path = fetch_pdb(pdb_id)
173
+ protein_source = path
174
+ if auth_is_logged_in():
175
+ save_fetched_to_user_cache(auth_get_user_id(), pdb_id, path)
176
+ except Exception as e:
177
+ st.sidebar.error(f"Request failed: {e}")
178
+ else:
179
+ uploaded = st.sidebar.file_uploader("Upload PDB or CIF", type=["pdb", "cif"])
180
+ if uploaded:
181
+ data = uploaded.read()
182
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(uploaded.name).suffix) as f:
183
+ f.write(data)
184
+ protein_source = f.name
185
+ pdb_id = Path(uploaded.name).stem
186
+ if auth_is_logged_in():
187
+ save_upload_to_user_cache(auth_get_user_id(), uploaded.name, data)
188
+
189
+ # Config
190
+ config = load_config()
191
+ monomers = load_monomers()
192
+ monomer_names = list(monomers.keys())
193
+ MAX_MONOMERS = 4
194
+
195
+ # Model selector
196
+ _model_labels = [f"{name} ({k})" for k, (name, _, _) in MODEL_TYPES.items()]
197
+ _model_keys = list(MODEL_TYPES.keys())
198
+ if GPR_AVAILABLE:
199
+ _model_labels.append("GPR (with uncertainty)")
200
+ _model_keys.append("gpr")
201
+ model_choice_idx = st.sidebar.selectbox(
202
+ "Prediction model",
203
+ range(len(_model_labels)),
204
+ format_func=lambda i: _model_labels[i],
205
+ help="RF | SVM | Ridge | Logistic | Gradient Boosting | KNN | GPR",
206
+ )
207
+ model_key = _model_keys[model_choice_idx]
208
+ use_gpr = model_key == "gpr"
209
+
210
+ def get_predictor():
211
+ if use_gpr:
212
+ return GPRStabilityPredictor()
213
+ return StabilityPredictor(use_surrogate=True, model_type=model_key)
214
+
215
+ # Auth0: trigger login via query param (for gradient link-button)
216
+ if auth0_available() and st.query_params.get("login") == "auth0":
217
+ st.login("auth0")
218
+ st.stop()
219
+
220
+ # Auth0: status in sidebar when logged in
221
+ if auth0_available() and hasattr(st, "user") and getattr(st.user, "is_logged_in", False):
222
+ st.sidebar.caption(f"Signed in as {getattr(st.user, 'name', getattr(st.user, 'email', ''))}")
223
+
224
+ # Persist composition in session state (updated in Structure tab)
225
+ if "composition" not in st.session_state:
226
+ st.session_state.composition = {name: 0.25 if i < 4 else 0.0 for i, name in enumerate(monomer_names)}
227
+ total = 1.0
228
+ st.session_state.composition = {k: v for k, v in st.session_state.composition.items() if v > 0}
229
+ if st.session_state.composition:
230
+ t = sum(st.session_state.composition.values())
231
+ st.session_state.composition = {k: v/t for k, v in st.session_state.composition.items()}
232
+
233
+ # Main content - header row (compact: no tall widget to avoid blank space)
234
+ header_col1, header_col2 = st.columns([3, 1])
235
+ with header_col1:
236
+ logo_col, title_col = st.columns([1, 4])
237
+ with logo_col:
238
+ if LOGO_PATH.exists():
239
+ _logo_b64 = __import__("base64").b64encode(LOGO_PATH.read_bytes()).decode()
240
+ st.markdown(f"""
241
+ <style>
242
+ @keyframes logo-rotate {{
243
+ 0% {{ transform: rotate(0deg); filter: drop-shadow(0 0 20px rgba(255, 80, 120, 0.5)) drop-shadow(0 0 6px rgba(255, 200, 220, 0.8)); }}
244
+ 12.5% {{ transform: rotate(45deg); filter: drop-shadow(0 0 20px rgba(255, 150, 80, 0.5)) drop-shadow(0 0 6px rgba(255, 220, 180, 0.8)); }}
245
+ 25% {{ transform: rotate(90deg); filter: drop-shadow(0 0 20px rgba(255, 220, 80, 0.5)) drop-shadow(0 0 6px rgba(255, 248, 200, 0.8)); }}
246
+ 37.5% {{ transform: rotate(135deg); filter: drop-shadow(0 0 20px rgba(120, 255, 100, 0.5)) drop-shadow(0 0 6px rgba(180, 255, 200, 0.8)); }}
247
+ 50% {{ transform: rotate(180deg); filter: drop-shadow(0 0 20px rgba(80, 220, 255, 0.5)) drop-shadow(0 0 6px rgba(180, 240, 255, 0.8)); }}
248
+ 62.5% {{ transform: rotate(225deg); filter: drop-shadow(0 0 20px rgba(80, 120, 255, 0.5)) drop-shadow(0 0 6px rgba(180, 200, 255, 0.8)); }}
249
+ 75% {{ transform: rotate(270deg); filter: drop-shadow(0 0 20px rgba(160, 80, 255, 0.5)) drop-shadow(0 0 6px rgba(220, 180, 255, 0.8)); }}
250
+ 87.5% {{ transform: rotate(315deg); filter: drop-shadow(0 0 20px rgba(255, 80, 180, 0.5)) drop-shadow(0 0 6px rgba(255, 180, 220, 0.8)); }}
251
+ 100% {{ transform: rotate(360deg); filter: drop-shadow(0 0 20px rgba(255, 80, 120, 0.5)) drop-shadow(0 0 6px rgba(255, 200, 220, 0.8)); }}
252
+ }}
253
+ .proteinpro-logo {{
254
+ width: 80px; display: block;
255
+ animation: logo-rotate 10s linear infinite;
256
+ }}
257
+ .proteinpro-logo:hover {{
258
+ animation: none;
259
+ transform: scale(1.08); filter: drop-shadow(0 0 24px rgba(255, 200, 255, 0.6)) drop-shadow(0 0 10px rgba(255, 255, 255, 0.7));
260
+ }}
261
+ </style>
262
+ <img src="data:image/png;base64,{_logo_b64}" class="proteinpro-logo" alt="ProteinPRO"/>
263
+ """, unsafe_allow_html=True)
264
+ else:
265
+ st.image("assets/logo.png", width=80)
266
+ with title_col:
267
+ st.title("ProteinPRO")
268
+ st.markdown("**Mapping Protein Chemistry to Polymer Chemistry**")
269
+ with header_col2:
270
+ st.markdown('<div style="height: 36px;"></div>', unsafe_allow_html=True)
271
+ if auth0_available():
272
+ try:
273
+ logged_in = getattr(st.user, "is_logged_in", False) if hasattr(st, "user") else False
274
+ if logged_in:
275
+ st.caption(f"Signed in as {getattr(st.user, 'name', getattr(st.user, 'email', 'User'))}")
276
+ if st.button("Log out", key="auth_logout", type="primary"):
277
+ st.logout()
278
+ else:
279
+ st.markdown("""
280
+ <style>
281
+ @keyframes auth0-breathe {
282
+ 0%, 100% { transform: scale(1); box-shadow: 0 2px 12px rgba(110, 84, 148, 0.35); }
283
+ 50% { transform: scale(1.04); box-shadow: 0 4px 24px rgba(110, 84, 148, 0.55), 0 0 20px rgba(139, 122, 184, 0.25); }
284
+ }
285
+ .auth0-btn {
286
+ display: inline-block; padding: 10px 20px; margin-bottom: 8px;
287
+ background: linear-gradient(135deg, #6e5494 0%, #8b7ab8 100%);
288
+ color: #fafafa !important; text-decoration: none;
289
+ border-radius: 50px; font-weight: 600; font-size: 14px;
290
+ text-align: center; border: 1px solid rgba(139, 122, 184, 0.5);
291
+ animation: auth0-breathe 2.5s ease-in-out infinite;
292
+ transition: transform 0.2s, box-shadow 0.2s;
293
+ }
294
+ .auth0-btn:hover {
295
+ animation: none;
296
+ transform: scale(1.06); box-shadow: 0 6px 28px rgba(110, 84, 148, 0.6);
297
+ color: #fafafa !important;
298
+ }
299
+ </style>
300
+ <div style="text-align: right;"><a href="?login=auth0" class="auth0-btn">Auth0 Login</a></div>
301
+ """, unsafe_allow_html=True)
302
+ except Exception:
303
+ st.markdown("""
304
+ <style>
305
+ @keyframes auth0-breathe{0%,100%{transform:scale(1);box-shadow:0 2px 12px rgba(110,84,148,0.35);}50%{transform:scale(1.04);box-shadow:0 4px 24px rgba(110,84,148,0.55);}}
306
+ .auth0-btn{display:inline-block;padding:10px 20px;margin-bottom:8px;background:linear-gradient(135deg,#6e5494,#8b7ab8);color:#fafafa!important;text-decoration:none;border-radius:50px;font-weight:600;font-size:14px;border:1px solid rgba(139,122,184,0.5);animation:auth0-breathe 2.5s ease-in-out infinite;}
307
+ .auth0-btn:hover{animation:none;transform:scale(1.06);}
308
+ </style>
309
+ <div style="text-align: right;"><a href="?login=auth0" class="auth0-btn">Auth0 Login</a></div>
310
+ """, unsafe_allow_html=True)
311
+
312
+ # Hero content block: immediately below header, centered (no nested container)
313
+ st.markdown("""
314
+ <div class="hero-block">
315
+ <p class="hero-mission">ProteinPRO predicts protein–polymer hybrid (PPH) formulation stability by matching chemical features from your protein structure to PET-RAFT monomer composition chemical features.</p>
316
+ <div class="hero-how">
317
+ <span class="hero-how-label">How it works</span>
318
+ <div class="hero-steps">
319
+ <div class="hero-step" data-step="1"><span class="hero-icon">🧬</span><span>Load protein</span></div>
320
+ <div class="hero-step" data-step="2"><span class="hero-icon">🔍</span><span>Explore protein features</span></div>
321
+ <div class="hero-step" data-step="3"><span class="hero-icon">⚗️</span><span>Select monomer compositions</span></div>
322
+ <div class="hero-step" data-step="4"><span class="hero-icon">📊</span><span>Predict stability</span></div>
323
+ <div class="hero-step" data-step="5"><span class="hero-icon">🔄</span><span>Optimize and iterate</span></div>
324
+ </div>
325
+ </div>
326
+ <div class="hero-powered">
327
+ <span class="hero-powered-label">Powered by</span>
328
+ <div class="hero-tech-marquee"><div class="hero-tech-inner"><span class="hero-tech-track">PDB · Biopython · RDKit · scikit-learn · py3Dmol · Streamlit · Gemini · ElevenLabs · Auth0</span><span class="hero-tech-track">PDB · Biopython · RDKit · scikit-learn · py3Dmol · Streamlit · Gemini · ElevenLabs · Auth0</span></div></div>
329
+ </div>
330
+ </div>
331
+ <style>
332
+ .hero-block { text-align: center; padding: 12px 16px 8px; max-width: 720px; margin: 0 auto; }
333
+ .hero-mission { font-size: 0.95rem; color: #94a3b8; line-height: 1.6; margin: 0 0 16px 0; }
334
+ .hero-how, .hero-powered { margin-top: 12px; }
335
+ .hero-how-label, .hero-powered-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; display: block; margin-bottom: 10px; }
336
+ .hero-steps { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px 20px; }
337
+ .hero-step { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 8px; font-size: 0.8rem; color: #94a3b8; background: rgba(139, 122, 184, 0.08); border: 1px solid rgba(139, 122, 184, 0.15); transition: all 0.3s ease; }
338
+ .hero-step .hero-icon { font-size: 1rem; }
339
+ .hero-steps .hero-step:nth-child(1) { animation: hero-pulse 5s ease-in-out infinite; }
340
+ .hero-steps .hero-step:nth-child(2) { animation: hero-pulse 5s ease-in-out infinite 1s; }
341
+ .hero-steps .hero-step:nth-child(3) { animation: hero-pulse 5s ease-in-out infinite 2s; }
342
+ .hero-steps .hero-step:nth-child(4) { animation: hero-pulse 5s ease-in-out infinite 3s; }
343
+ .hero-steps .hero-step:nth-child(5) { animation: hero-pulse 5s ease-in-out infinite 4s; }
344
+ @keyframes hero-pulse { 0%, 18%, 100% { opacity: 0.7; border-color: rgba(139, 122, 184, 0.15); background: rgba(139, 122, 184, 0.08); } 8% { opacity: 1; border-color: rgba(167, 139, 250, 0.4); background: rgba(139, 122, 184, 0.18); box-shadow: 0 0 12px rgba(110, 84, 148, 0.2); } }
345
+ .hero-tech-marquee { overflow: hidden; user-select: none; padding: 8px 0; }
346
+ .hero-tech-inner { display: flex; animation: hero-scroll 20s linear infinite; width: max-content; }
347
+ .hero-tech-track { flex-shrink: 0; padding: 0 24px; font-size: 0.8rem; color: #64748b; }
348
+ @keyframes hero-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
349
+ @media (max-width: 640px) { .hero-steps { flex-direction: column; align-items: center; } .hero-block { padding: 16px 12px; } }
350
+ </style>
351
+ """, unsafe_allow_html=True)
352
+
353
+
354
+ # Main mode: Protein Analysis vs Custom Data Analysis
355
+ main_tab_protein, main_tab_data = st.tabs(["Protein Analysis", "Custom Data Analysis"])
356
+
357
+ with main_tab_protein:
358
+ if protein_source:
359
+ # Parse once for all tabs
360
+ try:
361
+ structure = parse_structure(protein_source, pdb_id)
362
+ info = get_sequence_and_features(structure)
363
+ except Exception as e:
364
+ st.error(f"Failed to parse structure: {e}")
365
+ structure = None
366
+ info = {}
367
+
368
+ tab_features, tab_structure, tab3, tab5 = st.tabs(
369
+ ["Features", "Structure", "Prediction", "Ask Gemini"]
370
+ )
371
+
372
+ with tab_features:
373
+ st.subheader("Protein features & chemical landscape")
374
+ pf = featurize_protein(protein_source, pdb_id)
375
+ col_viewer, col_features = st.columns([1.2, 1])
376
+
377
+ with col_viewer:
378
+ if HAS_3D and structure:
379
+ try:
380
+ pdb_str = get_coordinates_for_visualization(structure)
381
+ roles = get_residue_roles_for_visualization(structure)
382
+ view = py3Dmol.view(width=500, height=380)
383
+ view.addModel(pdb_str, "pdb")
384
+ view.setStyle({"cartoon": {"color": "#b0b0b0", "opacity": 0.85}})
385
+ _COLORS = {"polar": "#6e5494", "positive": "#3b82f6", "negative": "#ef4444", "hydrophobic": "#f97316"}
386
+ for rtype, color in _COLORS.items():
387
+ pairs = roles.get(rtype, [])
388
+ if not pairs:
389
+ continue
390
+ by_chain = {}
391
+ for ch, resi in pairs:
392
+ by_chain.setdefault(ch, []).append(resi)
393
+ for ch, resis in by_chain.items():
394
+ view.setStyle({"chain": ch, "resi": resis}, {"cartoon": {"color": color, "thickness": 1.2}})
395
+ view.zoomTo()
396
+ view.spin(True)
397
+ st.components.v1.html(view.write_html(), height=400)
398
+ st.caption("🟣 Polar · 🔵 Positive · 🔴 Negative · 🟠 Hydrophobic")
399
+ except Exception as e:
400
+ st.warning(f"3D view error: {e}")
401
+ elif not HAS_3D:
402
+ st.info("Install py3Dmol for 3D visualization: pip install py3Dmol")
403
+ st.markdown('<div style="min-height: 200px;"></div>', unsafe_allow_html=True)
404
+ _convai_html = """<!DOCTYPE html><html><head><script src="https://unpkg.com/@elevenlabs/convai-widget-embed" async type="text/javascript"></script></head><body style="margin:0;padding:0;background:transparent;min-height:500px;"><elevenlabs-convai agent-id="agent_6001km9er5c3em99vsjb4x5fgw33" variant="compact" action-text="Chat with Gemini"></elevenlabs-convai></body></html>"""
405
+ st.components.v1.html(_convai_html, height=500, scrolling=True)
406
+
407
+ with col_features:
408
+ n = pf.get("n_residues", 0)
409
+ fracs = {
410
+ "Polar": pf.get("fraction_polar", 0),
411
+ "Positive": pf.get("fraction_positive", 0),
412
+ "Negative": pf.get("fraction_negative", 0),
413
+ "Hydrophobic": pf.get("fraction_hydrophobic", 0),
414
+ }
415
+ _bar_colors = {
416
+ "Polar": ("#8b7ab8", "#6e5494"),
417
+ "Positive": ("#5b9bd5", "#3b82f6"),
418
+ "Negative": ("#e57373", "#c62828"),
419
+ "Hydrophobic": ("#ffb74d", "#f57c00"),
420
+ }
421
+ _bars_html = "".join(
422
+ f'<div class="res-bar"><span class="res-label">{label}</span>'
423
+ f'<div class="res-track"><div class="res-fill" style="width:{int(round(val*100))}%;background:linear-gradient(90deg,{_bar_colors[label][0]},{_bar_colors[label][1]});"></div></div>'
424
+ f'<span class="res-pct">{int(round(val*100))}%</span></div>'
425
+ for label, val in fracs.items()
426
+ )
427
+ st.markdown(f"""
428
+ <style>
429
+ .res-composition {{ margin:0; padding:0; font-family:system-ui,sans-serif; }}
430
+ .res-composition-title {{ font-weight:600; font-size:14px; color:#e0e0e0; margin-bottom:10px; }}
431
+ .res-bars {{ display:flex; flex-direction:column; gap:8px; }}
432
+ .res-bar {{ display:flex; align-items:center; gap:10px; min-height:28px; }}
433
+ .res-label {{ flex:0 0 85px; font-size:12px; color:#b0b0b0; font-weight:500; }}
434
+ .res-track {{ flex:1; min-width:0; height:12px; background:rgba(255,255,255,0.06); border-radius:6px; overflow:hidden; box-shadow:inset 0 1px 2px rgba(0,0,0,0.2); border:1px solid rgba(255,255,255,0.04); }}
435
+ .res-fill {{ height:100%; border-radius:5px; transition:width 0.4s ease; box-shadow:0 0 8px rgba(0,0,0,0.15); }}
436
+ .res-pct {{ flex:0 0 38px; font-size:12px; color:#9ca3af; text-align:right; font-variant-numeric:tabular-nums; }}
437
+ @media (max-width: 640px) {{ .res-label {{ flex:0 0 70px; font-size:11px; }} .res-pct {{ flex:0 0 32px; }} }}
438
+ </style>
439
+ <div class="res-composition">
440
+ <div class="res-composition-title">Residue composition</div>
441
+ <div class="res-bars">{_bars_html}</div>
442
+ </div>
443
+ """, unsafe_allow_html=True)
444
+ st.divider()
445
+ st.markdown("**Key Descriptors**")
446
+ m1, m2 = st.columns(2)
447
+ with m1:
448
+ st.metric("Residues", n)
449
+ h = pf.get("mean_hydrophobicity", 0)
450
+ st.metric("Hydrophobicity", f"{h:.2f}")
451
+ with m2:
452
+ q = pf.get("net_charge_density", 0)
453
+ st.metric("Net charge density", f"{q:.3f}")
454
+ st.metric("Std hydrophobicity", f"{pf.get('std_hydrophobicity', 0):.2f}")
455
+
456
+ st.divider()
457
+ st.markdown("**Suggested Monomers**")
458
+ suggestions = []
459
+ if pf.get("fraction_positive", 0) > 0.15:
460
+ suggestions.append(("Anionic SPMA", "Balances positive charge for favorable interactions", "anionic", "#06b6d4"))
461
+ if pf.get("fraction_negative", 0) > 0.15:
462
+ suggestions.append(("Cationic TMAEMA / DEAEMA", "Balances negative charge", "cationic", "#3b82f6"))
463
+ if pf.get("fraction_hydrophobic", 0) > 0.35:
464
+ suggestions.append(("BMA or EHMA", "Match hydrophobic patches", "hydrophobic", "#f97316"))
465
+ if pf.get("fraction_polar", 0) > 0.2:
466
+ suggestions.append(("HPMA or PEGMA", "Complement polar regions", "neutral_hydrophilic", "#22c55e"))
467
+ if pf.get("mean_hydrophobicity", 0) > 0.5:
468
+ suggestions.append(("Hydrophobic blend", "Add BMA/EHMA for compatibility", "hydrophobic", "#f97316"))
469
+ if not suggestions:
470
+ suggestions.append(("HPMA + DEAEMA", "Versatile starting point for most proteins", "neutral_hydrophilic", "#22c55e"))
471
+ for name, reason, cat, col in suggestions[:4]:
472
+ st.markdown(f"""
473
+ <div style="background:linear-gradient(135deg,{col}22,{col}08);border-left:4px solid {col};padding:10px 12px;margin-bottom:8px;border-radius:6px;font-size:13px;">
474
+ <strong>{name}</strong><br><span style="color:#6b7280;">{reason}</span>
475
+ </div>
476
+ """, unsafe_allow_html=True)
477
+
478
+ with tab_structure:
479
+ col_struct, col_monomer = st.columns([1.1, 0.9])
480
+
481
+ with col_struct:
482
+ st.subheader("3D structure")
483
+ if HAS_3D and structure:
484
+ try:
485
+ pdb_str = get_coordinates_for_visualization(structure)
486
+ view = py3Dmol.view(width=600, height=420)
487
+ view.addModel(pdb_str, "pdb")
488
+ view.setStyle({"cartoon": {"color": "spectrum"}})
489
+ view.zoomTo()
490
+ view.spin(True)
491
+ st.components.v1.html(view.write_html(), height=440)
492
+ except Exception as e:
493
+ st.warning(f"3D view error: {e}")
494
+ elif not HAS_3D:
495
+ st.info("Install py3Dmol for 3D visualization: pip install py3Dmol")
496
+
497
+ if info:
498
+ m1, m2, m3 = st.columns(3)
499
+ with m1:
500
+ st.metric("Residues", info["n_residues"])
501
+ with m2:
502
+ st.metric("Hydrophobicity", f"{info['mean_hydrophobicity']:.2f}")
503
+ with m3:
504
+ st.metric("Charge Density", f"{info['net_charge_density']:.3f}")
505
+
506
+ with col_monomer:
507
+ st.subheader("Monomer Composition")
508
+ st.caption(f"Select up to {MAX_MONOMERS} monomers and set molar fractions. The sum of the molar fractions should be 1.")
509
+
510
+ selected = st.multiselect(
511
+ "Monomers",
512
+ options=monomer_names,
513
+ default=list(st.session_state.composition.keys())[:MAX_MONOMERS] if st.session_state.composition else monomer_names[:2],
514
+ max_selections=MAX_MONOMERS,
515
+ key="monomer_multiselect",
516
+ )
517
+
518
+ if len(selected) > MAX_MONOMERS:
519
+ selected = selected[:MAX_MONOMERS]
520
+ st.warning(f"Limited to {MAX_MONOMERS} monomers.")
521
+
522
+ composition = {}
523
+ if selected:
524
+ n = len(selected)
525
+ default_frac = 1.0 / n
526
+ fracs = {}
527
+ for i, name in enumerate(selected):
528
+ fracs[name] = st.slider(
529
+ name,
530
+ min_value=0.05,
531
+ max_value=1.0,
532
+ value=float(st.session_state.composition.get(name, default_frac)),
533
+ step=0.05,
534
+ key=f"frac_{name}",
535
+ )
536
+ total = sum(fracs.values())
537
+ if total > 0:
538
+ composition = {k: v / total for k, v in fracs.items()}
539
+ st.session_state.composition = composition
540
+ st.metric("Total", f"{total:.2f}" + (" (normalized)" if abs(total - 1.0) > 0.01 else ""))
541
+
542
+ # Polymer descriptors (weighted)
543
+ st.divider()
544
+ st.subheader("Polymer Descriptors (weighted)")
545
+ st.caption("Chemical properties of your monomer blend, averaged by molar fraction. These chemical properties are featurized and used as training data for a stability model to predict protein–polymer compatibility.")
546
+ with st.expander("What do these descriptors mean?"):
547
+ st.markdown("""
548
+ **Core Descriptors (from RDKit):**
549
+ - **MolWt** — Molecular weight (g/mol). Larger polymers can affect diffusion and binding.
550
+ - **LogP** — Partition coefficient (lipophilicity). High = hydrophobic; low = hydrophilic. Affects how well the polymer matches protein surface chemistry and potential future binding.
551
+ - **TPSA** — Topological polar surface area (Ų). Measures polarity and hydrogen-bonding potential.
552
+ - **NumHDonors** / **NumHAcceptors** — Ratio of H-bond donors and acceptors. Important for polar interactions with relevant protein side chains.
553
+ - **FractionCSP3** — Fraction of carbons that are sp³ (saturated). Higher value = more flexible.
554
+
555
+ **Why "weighted"?** Your polymer is a mixture of 1-4 monomers. Each descriptor is defined by a molar-fraction-weighted average. For example, 50% HPMA with 50% BMA gives a LogP comprised of a weighted .50 for HPMA and .50 for BMA.
556
+ """)
557
+ mf_df = featurize_all_monomers()
558
+ poly_f = composition_to_polymer_features(composition, mf_df)
559
+ _DESC_LABELS = {
560
+ "MolWt": "Molecular Weight (g/mol)",
561
+ "LogP": "Log Partition Coefficient (lipophilicity)",
562
+ "TPSA": "Total Polar Surface Area (Ų)",
563
+ "NumHDonors": "H-bond Donors",
564
+ "NumHAcceptors": "H-bond Acceptors",
565
+ "FractionCSP3": "Carbon Saturation (CSP3)",
566
+ }
567
+ main_desc = {k: v for k, v in poly_f.items() if k in _DESC_LABELS and isinstance(v, (int, float))}
568
+ if main_desc:
569
+ for k, v in main_desc.items():
570
+ label = _DESC_LABELS.get(k, k)
571
+ st.metric(label, f"{v:.2f}" if isinstance(v, float) else v)
572
+ with st.expander("All descriptors (raw)"):
573
+ st.json(poly_f)
574
+ else:
575
+ st.info("Select at least one monomer.")
576
+
577
+ with tab3:
578
+ st.subheader("Stability prediction")
579
+ active_comp = {k: v for k, v in st.session_state.composition.items() if v > 0}
580
+ if active_comp:
581
+ predictor = get_predictor()
582
+ score, details = predictor.predict(protein_source, active_comp, pdb_id)
583
+ if use_gpr and "uncertainty_scaled" in details:
584
+ unc = details["uncertainty_scaled"]
585
+ st.metric("Stability score", f"{score:.4f} ± {unc:.3f}")
586
+ else:
587
+ st.metric("Stability score", f"{score:.4f}")
588
+ st.caption("Higher = more favorable (surrogate model)")
589
+ with st.expander("Prediction details"):
590
+ st.markdown("**Score equations**")
591
+ if use_gpr:
592
+ st.latex(r"\text{mean}, \sigma = \text{GPR}(\mathbf{x}_{\text{scaled}}) \quad \text{(Matern kernel)}")
593
+ else:
594
+ _m = {"rf": "RF", "svr": "SVR", "ridge": "Ridge", "logistic": "Logistic", "gradient_boosting": "GB", "knn": "KNN"}.get(model_key, "Model")
595
+ st.latex(r"\text{raw\_score} = \text{" + _m + r"}(\mathbf{x}_{\text{scaled}})")
596
+ st.latex(r"\text{score} = \frac{\tanh(\text{raw\_score}/50) + 1}{2} \quad \in [0, 1]")
597
+ st.markdown("*Surrogate objectives:*")
598
+ st.latex(r"0.3\,(1 - |H_{\text{prot}} - H_{\text{poly}}|) + 0.3\,(1 - |q_{\text{net}}|) + 0.2\,\text{polarity}")
599
+ st.markdown("---")
600
+ st.markdown("**Computed values**")
601
+ st.json({k: v for k, v in details.items() if k != "hydrophobicity_profile" and k != "charge_profile"})
602
+ else:
603
+ st.warning("Select at least one monomer")
604
+
605
+ st.divider()
606
+ st.subheader("Monomer combinations to explore")
607
+ st.caption("Sample and rank formulation compositions for optimal protein–polymer stability.")
608
+ n_samples = st.slider("Number of formulations to sample", 10, 200, 50, key="prediction_design_n")
609
+ if st.button("Rank formulations", key="prediction_rank_btn"):
610
+ predictor = get_predictor()
611
+ df = predictor.rank_formulations(protein_source, n_candidates=n_samples, pdb_id=pdb_id)
612
+ display_cols = ["composition", "stability_score"]
613
+ if "uncertainty" in df.columns:
614
+ display_cols.append("uncertainty")
615
+ st.dataframe(
616
+ df[display_cols].head(20),
617
+ use_container_width=True,
618
+ )
619
+ buf = io.StringIO()
620
+ df.to_csv(buf, index=False)
621
+ st.download_button("Download full results (CSV)", buf.getvalue(), file_name="formulation_rankings.csv", mime="text/csv", key="dl_prediction_rankings")
622
+
623
+ with tab5:
624
+ st.subheader("Ask RAG-enabled AI (Gemini)")
625
+ st.caption("Best Use of Generative AI - Hack Duke 2026")
626
+ active_comp = {k: v for k, v in st.session_state.composition.items() if v > 0}
627
+ if active_comp:
628
+ pf = featurize_protein(protein_source, pdb_id)
629
+ predictor = get_predictor()
630
+ score, _ = predictor.predict(protein_source, active_comp, pdb_id)
631
+ summary = f"{pf['n_residues']} residues, hydrophobicity {pf['mean_hydrophobicity']:.2f}, charge {pf['net_charge_density']:.2f}"
632
+ question = st.text_input("Ask me about formulation optimization", placeholder="e.g. If I were to try to optimize stability of lipase upon thermal heating, which monomers should I turn to first??")
633
+ if st.button("Get AI insight via an informed RAG pipeline"):
634
+ if question:
635
+ with st.spinner("Querying Gemini..."):
636
+ answer = ask_formulation_advice(summary, active_comp, score, question)
637
+ st.write(answer)
638
+ else:
639
+ st.warning("Enter a question")
640
+ else:
641
+ st.info("Select monomers in the Structure tab first.")
642
+ else:
643
+ st.info("Enter a PDB ID or upload a structure (PDB/CIF) to begin.")
644
+
645
+ with main_tab_data:
646
+ st.subheader("Custom Data Analysis")
647
+ if read_round_file is None or run_analysis is None:
648
+ st.warning("Analysis module not available. Install openpyxl: pip install openpyxl")
649
+ else:
650
+ st.markdown("**Upload Stability Data (Excel, please!)**")
651
+ st.caption(
652
+ "Upload one or more Excel files from previous polymerization design rounds. "
653
+ "Expects columns: performance (Average_REA_across_days or similar), optional monomers (DEAEMA, HPMA, etc.), Degree of Polymerization. "
654
+ "Multiple rounds of data can be uploaded and analyzed concurrently; simply label the columns by round number."
655
+ )
656
+ uploaded_files = st.file_uploader(
657
+ "Choose Excel file(s)",
658
+ type=["xlsx", "xls"],
659
+ accept_multiple_files=True,
660
+ key="stability_data_upload",
661
+ )
662
+ if uploaded_files:
663
+ all_dfs = []
664
+ errors = []
665
+ for uf in uploaded_files:
666
+ try:
667
+ df = read_round_file(uf.read(), uf.name)
668
+ all_dfs.append(df)
669
+ st.success(f"Loaded: {uf.name} ({len(df)} rows)")
670
+ except Exception as e:
671
+ errors.append(f"{uf.name}: {e}")
672
+ if errors:
673
+ for err in errors:
674
+ st.error(err)
675
+ if all_dfs:
676
+ data = pd.concat(all_dfs, ignore_index=True).sort_values("Round").reset_index(drop=True)
677
+ st.caption("Combined data (first 50 rows)")
678
+ st.dataframe(data.head(50), use_container_width=True)
679
+ if st.button("Run analysis", key="run_stability_analysis"):
680
+ with st.spinner("Running pipeline... vite vite!"):
681
+ try:
682
+ summary, figures = run_analysis(data)
683
+ st.session_state["custom_analysis_summary"] = summary
684
+ st.session_state["custom_analysis_figures"] = figures
685
+ st.session_state["custom_analysis_data"] = data
686
+ st.success("Hey, your analysis iscomplete! View your results below under Design Space Exploration.")
687
+ except Exception as e:
688
+ st.error(f"Analysis failed: {e}")
689
+
690
+ st.divider()
691
+ st.subheader("Design Space Exploration")
692
+ st.caption("Analysis results from your uploaded stability data.")
693
+ if "custom_analysis_summary" in st.session_state and "custom_analysis_figures" in st.session_state:
694
+ summary = st.session_state["custom_analysis_summary"]
695
+ figures = st.session_state["custom_analysis_figures"]
696
+ st.markdown("**Summary Metrics**")
697
+ st.dataframe(summary, use_container_width=True)
698
+ st.markdown("**Figures**")
699
+ n_figs = len(figures)
700
+ if n_figs > 0:
701
+ for i in range(0, n_figs, 2):
702
+ cols = st.columns(2)
703
+ for j, col in enumerate(cols):
704
+ idx = i + j
705
+ if idx < n_figs:
706
+ with col:
707
+ title, png_bytes = figures[idx]
708
+ st.markdown(f"**{title}**")
709
+ st.image(png_bytes, use_container_width=True)
710
+ if st.session_state.get("custom_analysis_data") is not None:
711
+ import zipfile
712
+ buf = io.BytesIO()
713
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
714
+ zf.writestr("round_summary.csv", summary.to_csv(index=False))
715
+ for title, png_bytes in figures:
716
+ safe_name = re.sub(r"[^\w\-]", "_", title) + ".png"
717
+ zf.writestr(safe_name, png_bytes)
718
+ buf.seek(0)
719
+ st.download_button("Download results (ZIP)", buf.getvalue(), file_name="stability_analysis.zip", mime="application/zip", key="dl_stability_zip")
720
+ else:
721
+ st.info("Upload Excel file(s) above and click **Run analysis** to see results here.")
722
 
723
+ # Footer - pushed down so it's not always in view
724
+ st.markdown("""
725
+ <style>
726
+ .footer-container {
727
+ margin-top: 320px;
728
+ padding: 32px 24px;
729
+ background: linear-gradient(180deg, rgba(110, 84, 148, 0.12) 0%, rgba(110, 84, 148, 0.04) 100%);
730
+ border-top: 1px solid rgba(139, 122, 184, 0.25);
731
+ border-radius: 12px 12px 0 0;
732
+ }
733
+ .footer-title {
734
+ font-size: 1.1rem;
735
+ font-weight: 600;
736
+ color: #a78bfa;
737
+ margin-bottom: 4px;
738
+ }
739
+ .footer-creator {
740
+ font-size: 0.95rem;
741
+ color: #94a3b8;
742
+ margin-bottom: 20px;
743
+ }
744
+ .footer-creator a {
745
+ color: #8b7ab8;
746
+ text-decoration: none;
747
+ font-weight: 500;
748
+ }
749
+ .footer-creator a:hover {
750
+ text-decoration: underline;
751
+ color: #a78bfa;
752
+ }
753
+ .footer-badges {
754
+ display: flex;
755
+ flex-wrap: wrap;
756
+ gap: 8px;
757
+ align-items: center;
758
+ margin-top: 16px;
759
+ }
760
+ .footer-badges a {
761
+ display: inline-block;
762
+ transition: transform 0.2s;
763
+ }
764
+ .footer-badges a:hover {
765
+ transform: translateY(-2px);
766
+ }
767
+ .footer-event {
768
+ font-size: 0.85rem;
769
+ color: #64748b;
770
+ margin-top: 20px;
771
+ }
772
+ </style>
773
+ <div class="footer-container">
774
+ <div class="footer-title">ProteinPRO</div>
775
+ <div class="footer-creator">Created by <a href="https://linkedin.com/in/ganttmeredith" target="_blank">@ganttmeredith</a></div>
776
+ <div class="footer-badges">
777
+ <img src="https://img.shields.io/badge/GitHub-Repository-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub" style="height:24px;" title="GitHub"/>
778
+ <a href="https://streamlit.io" target="_blank" title="Streamlit">
779
+ <img src="https://img.shields.io/badge/Streamlit-FF4B4B?style=flat-square&logo=streamlit&logoColor=white" alt="Streamlit" style="height:24px;"/>
780
+ </a>
781
+ <a href="https://ai.google.dev/gemini-api" target="_blank" title="Gemini AI">
782
+ <img src="https://img.shields.io/badge/Gemini%20AI-4285F4?style=flat-square&logo=google&logoColor=white" alt="Gemini AI" style="height:24px;"/>
783
+ </a>
784
+ <a href="https://elevenlabs.io" target="_blank" title="ElevenLabs">
785
+ <img src="https://img.shields.io/badge/ElevenLabs-000000?style=flat-square&logo=elevenlabs&logoColor=white" alt="ElevenLabs" style="height:24px;"/>
786
+ </a>
787
+ <a href="https://www.digitalocean.com" target="_blank" title="DigitalOcean">
788
+ <img src="https://img.shields.io/badge/DigitalOcean-0080FF?style=flat-square&logo=digitalocean&logoColor=white" alt="DigitalOcean" style="height:24px;"/>
789
+ </a>
790
+ <a href="https://auth0.com" target="_blank" title="Auth0">
791
+ <img src="https://img.shields.io/badge/Auth0-EB5424?style=flat-square&logo=auth0&logoColor=white" alt="Auth0" style="height:24px;"/>
792
+ </a>
793
+ <img src="https://img.shields.io/badge/Python-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python" style="height:24px;" title="Python"/>
794
+ <img src="https://img.shields.io/badge/RDKit-Chemical%20Featurization-4B5563?style=flat-square" alt="RDKit" style="height:24px;" title="RDKit"/>
795
+ <img src="https://img.shields.io/badge/Biopython-2D2A2E?style=flat-square" alt="Biopython" style="height:24px;" title="Biopython"/>
796
+ </div>
797
+ <div class="footer-event">Hack Duke 2026 — Code for Good — Gantt Meredith, Orator</div>
798
+ </div>
799
+ """, unsafe_allow_html=True)