Rami-Troudi commited on
Commit
673a52e
·
1 Parent(s): ca41776

Fix explorer/ingestion UI and 3D endpoints

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +2 -0
  2. ROADMAP.md +6 -0
  3. SCOPE_LOCK.md +38 -0
  4. UC4_GAP_ANALYSIS.md +341 -0
  5. bioflow/agents/workflow.py +57 -4
  6. bioflow/api/qdrant_service.py +214 -19
  7. bioflow/api/server.py +820 -49
  8. bioflow/app.py +0 -569
  9. bioflow/demo.py +6 -3
  10. bioflow/evaluation/__init__.py +21 -0
  11. bioflow/evaluation/metrics.py +82 -0
  12. bioflow/ingestion/pubmed_ingestor.py +12 -0
  13. bioflow/pipeline.py +3 -0
  14. bioflow/qdrant_manager.py +3 -0
  15. bioflow/search/enhanced_search.py +234 -27
  16. bioflow/ui/__init__.py +0 -15
  17. bioflow/ui/app.py +0 -61
  18. bioflow/ui/components.py +0 -481
  19. bioflow/ui/config.py +0 -583
  20. bioflow/ui/pages/__init__.py +0 -5
  21. bioflow/ui/pages/data.py +0 -163
  22. bioflow/ui/pages/discovery.py +0 -165
  23. bioflow/ui/pages/explorer.py +0 -127
  24. bioflow/ui/pages/home.py +0 -213
  25. bioflow/ui/pages/settings.py +0 -192
  26. bioflow/ui/requirements.txt +0 -31
  27. docs/BIOFLOW_OBM_REPORT.md +12 -14
  28. docs/COMPLIANCE_REPORT.md +36 -0
  29. docs/FRONTEND_FALLBACKS.md +40 -0
  30. docs/INGESTION_GUIDE.md +95 -0
  31. docs/METADATA_SCHEMA.md +62 -0
  32. docs/OBSERVABILITY.md +45 -0
  33. docs/ROADMAP.md +14 -68
  34. launch_bioflow.bat +10 -2
  35. launch_bioflow_full.bat +1 -1
  36. launch_ui.py +11 -19
  37. open_biomed/__init__.py +17 -6
  38. open_biomed/core/llm_request.py +53 -42
  39. open_biomed/data/__init__.py +7 -2
  40. open_biomed/data/molecule.py +12 -3
  41. qdrant_data/.lock +0 -1
  42. qdrant_data/collection/bioflow_memory/storage.sqlite +0 -3
  43. qdrant_data/collection/molecules/storage.sqlite +0 -3
  44. qdrant_data/meta.json +0 -1
  45. requirements.txt +2 -3
  46. scripts/benchmark_mmr.py +53 -0
  47. scripts/benchmark_search_api.py +93 -0
  48. scripts/evaluate_retrieval.py +99 -0
  49. scripts/evidence_audit.py +48 -0
  50. scripts/run_tests.py +36 -0
.gitignore CHANGED
@@ -145,6 +145,8 @@ cython_debug/
145
  /misc/*
146
  /tmp/*
147
  /third_party/*
 
 
148
  !/third_party/.placeholder
149
  !/third_party/p2rank_2.5
150
  !/checkpoints/.placeholder
 
145
  /misc/*
146
  /tmp/*
147
  /third_party/*
148
+ /qdrant_data/
149
+ /stress_test_report.json
150
  !/third_party/.placeholder
151
  !/third_party/p2rank_2.5
152
  !/checkpoints/.placeholder
ROADMAP.md CHANGED
@@ -196,6 +196,12 @@ Each phase builds on the previous one, with clear deliverables and success crite
196
  - [ ] Search latency < 500ms for 10k vectors
197
  - [ ] System handles 10 concurrent users
198
 
 
 
 
 
 
 
199
  ---
200
 
201
  ## Phase 6: Advanced Features (Future)
 
196
  - [ ] Search latency < 500ms for 10k vectors
197
  - [ ] System handles 10 concurrent users
198
 
199
+ ### Implementation Notes (added 2026-01-27)
200
+
201
+ - `bioflow/evaluation/metrics.py` provides Recall@k, MRR@k, nDCG@k, and a cosine intra-list diversity metric.
202
+ - `scripts/evaluate_retrieval.py` evaluates `/api/search` against a user-provided benchmark JSON.
203
+ - `scripts/benchmark_search_api.py` benchmarks `/api/search` latency with configurable concurrency.
204
+
205
  ---
206
 
207
  ## Phase 6: Advanced Features (Future)
SCOPE_LOCK.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioFlow Scope Lock (Phase 0)
2
+
3
+ **Status:** Confirmed by user
4
+ **Date:** 2026-01-27
5
+
6
+ ## Single Source of Truth Runtime
7
+ - **Backend:** FastAPI on port `8000`
8
+ - **Frontend:** Next.js on port `3000`
9
+ - **Vector DB:** Qdrant on port `6333`
10
+ - **Canonical pipeline:** Agents + Enhanced Search
11
+ - **Legacy:** Streamlit + legacy pipeline are deprecated and must not be on the runtime path
12
+
13
+ ## Open‑Source Only Constraint
14
+ - **No proprietary dependencies** (OpenAI / Azure OpenAI / InstaDeep / closed models)
15
+ - **All referenced models must be open‑source**
16
+
17
+ ## OBM Role
18
+ - OBM is the **multimodal embedding backbone only** (text / SMILES / protein)
19
+ - OBM is **not** the generator, validator, or orchestrator
20
+
21
+ ## Qdrant Requirement
22
+ Qdrant must be implemented **to the fullest extent needed** for the project, including:
23
+ - **HNSW indexing** for scalable similarity search
24
+ - **Payload metadata + filtering** for evidence, modality, source, organism, dates
25
+ - **Collections** and **collection discovery** for multi‑source datasets
26
+ - **Efficient paging/scrolling** for UI listings and explorer views
27
+ - **Multi‑vector or named vectors** where required by modality
28
+ - **Top‑K filtered retrieval** and **context injection** into agents
29
+
30
+ ## Audit Deliverable (Phase 0 Definition of Done)
31
+ The “Full Audit” report must include:
32
+ 1. **Checklist vs requirements**
33
+ 2. **Gaps & risks**
34
+ 3. **Performance bottlenecks**
35
+ 4. **Security/compliance review**
36
+ 5. **Prioritized remediation plan**
37
+ 6. **Appendix: API + data schemas**
38
+
UC4_GAP_ANALYSIS.md ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UC4 BioFlow Gap Analysis & Action Plan
2
+
3
+ ## Executive Summary
4
+
5
+ **Stress Test Results:** 21/21 tests PASSED ✅
6
+ **Warnings Identified:** 6
7
+ **Test Duration:** 130.8 seconds
8
+
9
+ All core functionality is operational. This document identifies gaps against the UC4 vision and proposes enhancements.
10
+
11
+ ---
12
+
13
+ ## 1. Current State Assessment
14
+
15
+ ### ✅ Working Features (Phase 1-4 Complete)
16
+
17
+ | Feature | Status | Notes |
18
+ |---------|--------|-------|
19
+ | Text Ingestion (PubMed) | ✅ Working | 2.2s per document |
20
+ | Molecule Ingestion (ChEMBL) | ✅ Working | 12.9s for 5 molecules |
21
+ | Protein Ingestion (UniProt) | ✅ Working | 5.2s for 2 sequences |
22
+ | Semantic Search | ✅ Working | 8.4s for 4 queries |
23
+ | MMR Diversification | ✅ Working | Diversity score in response |
24
+ | Filtered Search | ✅ Working | 5/5 filters functional |
25
+ | Evidence Linking | ✅ Working | With warnings |
26
+ | Molecule Generation | ✅ Working | 8.1s for 4 prompts |
27
+ | Molecule Mutation | ✅ Working | 2.1s per batch |
28
+ | ADMET Validation | ✅ Working | Lipinski, QED, alerts |
29
+ | Multi-Criteria Ranking | ✅ Working | Configurable weights |
30
+ | Full Workflow Pipeline | ✅ Working | Generate→Validate→Rank |
31
+ | 3D Visualization Page | ✅ Working | CSS 3D transforms |
32
+ | Workflow Builder Page | ✅ Working | Visual step cards |
33
+ | Discovery Page | ✅ Working | Search interface |
34
+ | Concurrent Searches | ✅ Working | 10/10 parallel |
35
+
36
+ ---
37
+
38
+ ## 2. Gaps Identified from UC4 Vision
39
+
40
+ ### 2.1 Critical Gaps (High Priority)
41
+
42
+ #### 🔴 GAP-1: Source Metadata Consistency
43
+ **Warning:** "No results have source metadata"
44
+ **Root Cause:** Ingested data doesn't always include `source` field in payload
45
+ **Impact:** Evidence traceability compromised
46
+
47
+ **Fix Required:**
48
+ ```python
49
+ # In enhanced_search.py - normalize source extraction
50
+ def _extract_source(self, result):
51
+ payload = result.payload
52
+ # Try multiple source fields
53
+ return payload.get('source') or payload.get('database') or payload.get('origin') or 'unknown'
54
+ ```
55
+
56
+ #### 🔴 GAP-2: Cross-Modal Search Returns Single Modality
57
+ **Warning:** "single modality results" for cross-modal queries
58
+ **Root Cause:** Embedding space not aligned across modalities
59
+ **Impact:** Can't discover molecules from text queries
60
+
61
+ **Fix Required:**
62
+ 1. Implement multimodal embedding alignment layer
63
+ 2. Create cross-modal projection matrix
64
+ 3. Or use unified encoder that maps all modalities to same space
65
+
66
+ #### 🔴 GAP-3: Slow Batch Ingestion (0.4 items/sec)
67
+ **Warning:** "Slow ingestion: 0.4 items/sec"
68
+ **Root Cause:** Sequential encoding + no batch vectorization
69
+ **Impact:** Cannot scale to large datasets
70
+
71
+ **Fix Required:**
72
+ ```python
73
+ # In ingest endpoint - batch processing
74
+ async def batch_ingest(items: List[IngestRequest]):
75
+ # Vectorize all at once
76
+ embeddings = encoder.encode_batch([i.content for i in items])
77
+ # Batch upsert to Qdrant
78
+ qdrant.upsert(collection, points=points, batch_size=100)
79
+ ```
80
+
81
+ #### 🔴 GAP-4: Scientific Traceability Incomplete
82
+ **Warning:** "Only 2/5 results are traceable"
83
+ **Root Cause:** Evidence links not generated for all sources
84
+ **Impact:** Scientists can't verify claims
85
+
86
+ **Fix Required:**
87
+ 1. Mandatory source field during ingestion
88
+ 2. Auto-generate evidence links for all known sources
89
+ 3. Add citation formatter
90
+
91
+ ---
92
+
93
+ ### 2.2 Moderate Gaps (Medium Priority)
94
+
95
+ #### 🟡 GAP-5: Missing "Navigate Neighbors" Feature
96
+ **UC4 Requirement:** "Guided exploration—navigate neighbors"
97
+ **Status:** Not implemented
98
+ **Description:** Ability to explore similar items from any result
99
+
100
+ **Implementation Plan:**
101
+ ```
102
+ POST /api/search/neighbors
103
+ {
104
+ "point_id": "abc123",
105
+ "top_k": 10,
106
+ "exclude_self": true
107
+ }
108
+ ```
109
+
110
+ #### 🟡 GAP-6: No Faceted Search
111
+ **UC4 Requirement:** "Facets and filtering"
112
+ **Status:** Basic filters only
113
+ **Missing:** Dynamic facet counts, aggregations
114
+
115
+ **Implementation Plan:**
116
+ ```
117
+ GET /api/search/facets?query=kinase
118
+ Response: {
119
+ "modality": {"text": 45, "molecule": 23, "protein": 12},
120
+ "source": {"pubmed": 40, "chembl": 30, "uniprot": 10},
121
+ "organism": {"human": 60, "mouse": 20}
122
+ }
123
+ ```
124
+
125
+ #### 🟡 GAP-7: No Image Modality Support
126
+ **UC4 Requirement:** "Multimodal: text, sequences, structures, images, measurements"
127
+ **Status:** Missing images and measurements
128
+ **Impact:** Can't process microscopy, gel images
129
+
130
+ **Implementation Plan:**
131
+ 1. Add CLIP/BiomedCLIP encoder for images
132
+ 2. Create image ingestion endpoint
133
+ 3. Implement image-to-molecule similarity
134
+
135
+ #### 🟡 GAP-8: No Structure Similarity (3D)
136
+ **UC4 Requirement:** "Structure similarity"
137
+ **Status:** SMILES/fingerprint only
138
+ **Impact:** Can't find 3D conformer matches
139
+
140
+ **Implementation Plan:**
141
+ 1. Integrate Open Babel for 3D generation
142
+ 2. Add 3D fingerprints (USRCAT, E3FP)
143
+ 3. Implement structure alignment scoring
144
+
145
+ ---
146
+
147
+ ### 2.3 Enhancement Opportunities (Low Priority)
148
+
149
+ #### 🟢 ENH-1: Result Diversity Metrics
150
+ Add quantitative diversity score to all search results.
151
+
152
+ #### 🟢 ENH-2: Feedback Learning Loop
153
+ Implement user feedback collection for ranking refinement.
154
+
155
+ #### 🟢 ENH-3: Export to Common Formats
156
+ - SDF for molecules
157
+ - FASTA for proteins
158
+ - RIS for citations
159
+
160
+ #### 🟢 ENH-4: Workflow Templates
161
+ Pre-built workflows for common discovery patterns.
162
+
163
+ #### 🟢 ENH-5: Batch Validation API
164
+ Validate 100s of molecules in single request.
165
+
166
+ #### 🟢 ENH-6: Protein Structure Prediction
167
+ Integrate ESMFold for structure predictions.
168
+
169
+ #### 🟢 ENH-7: Real-Time Notifications
170
+ WebSocket updates for long-running workflows.
171
+
172
+ #### 🟢 ENH-8: Collaboration Features
173
+ Shared workspaces, annotations, discussions.
174
+
175
+ ---
176
+
177
+ ## 3. Technical Bottlenecks
178
+
179
+ ### ⚡ BOTTLENECK-1: Encoding Latency
180
+ **Current:** ~2s per encoding operation
181
+ **Target:** <100ms
182
+ **Cause:** Loading models on each request
183
+
184
+ **Solution:**
185
+ - Pre-load models at startup
186
+ - Use model caching
187
+ - Consider ONNX optimization
188
+
189
+ ### ⚡ BOTTLENECK-2: Sequential Pipeline Steps
190
+ **Current:** Generate→Validate→Rank runs sequentially
191
+ **Target:** Parallel where possible
192
+
193
+ **Solution:**
194
+ ```python
195
+ # Parallel validation
196
+ async def validate_batch(smiles_list):
197
+ tasks = [validate_single(s) for s in smiles_list]
198
+ return await asyncio.gather(*tasks)
199
+ ```
200
+
201
+ ### ⚡ BOTTLENECK-3: Memory Usage with Large Collections
202
+ **Current:** Full PCA on all points
203
+ **Risk:** OOM with 1M+ vectors
204
+
205
+ **Solution:**
206
+ - Incremental PCA
207
+ - Sample-based visualization
208
+ - Pagination for large results
209
+
210
+ ### ⚡ BOTTLENECK-4: No GPU Utilization Check
211
+ **Current:** Assumes CPU
212
+ **Impact:** Slow encoding
213
+
214
+ **Solution:**
215
+ ```python
216
+ import torch
217
+ device = "cuda" if torch.cuda.is_available() else "cpu"
218
+ model = model.to(device)
219
+ ```
220
+
221
+ ---
222
+
223
+ ## 4. Action Plan
224
+
225
+ ### Phase 5A: Quick Wins (1-2 days)
226
+
227
+ | Task | Priority | Effort | Impact |
228
+ |------|----------|--------|--------|
229
+ | Fix source metadata extraction | 🔴 High | 2h | Traceability |
230
+ | Add batch ingestion endpoint | 🔴 High | 4h | Performance |
231
+ | Implement neighbors endpoint | 🟡 Medium | 3h | Exploration |
232
+ | Pre-load encoders at startup | 🟡 Medium | 2h | Latency |
233
+ | Add faceted search | 🟡 Medium | 4h | UX |
234
+
235
+ ### Phase 5B: Cross-Modal Alignment (3-5 days)
236
+
237
+ | Task | Priority | Effort | Impact |
238
+ |------|----------|--------|--------|
239
+ | Research alignment methods | 🔴 High | 4h | Architecture |
240
+ | Implement projection layer | 🔴 High | 8h | Core feature |
241
+ | Test cross-modal retrieval | 🔴 High | 4h | Validation |
242
+ | Add unified embedding space | 🔴 High | 8h | UC4 compliance |
243
+
244
+ ### Phase 5C: New Modalities (5-7 days)
245
+
246
+ | Task | Priority | Effort | Impact |
247
+ |------|----------|--------|--------|
248
+ | Add BiomedCLIP encoder | 🟡 Medium | 8h | Images |
249
+ | Image ingestion API | 🟡 Medium | 4h | API |
250
+ | 3D structure support | 🟡 Medium | 8h | Molecules |
251
+ | Measurement data support | 🟢 Low | 6h | Assays |
252
+
253
+ ### Phase 5D: Production Hardening (3-5 days)
254
+
255
+ | Task | Priority | Effort | Impact |
256
+ |------|----------|--------|--------|
257
+ | Add GPU detection | 🟡 Medium | 2h | Performance |
258
+ | Implement caching layer | 🟡 Medium | 6h | Latency |
259
+ | Add rate limiting | 🟡 Medium | 3h | Stability |
260
+ | Monitoring & alerts | 🟢 Low | 4h | Ops |
261
+ | Load testing | 🟢 Low | 4h | Validation |
262
+
263
+ ---
264
+
265
+ ## 5. Recommended Next Steps
266
+
267
+ ### Immediate (Today)
268
+
269
+ 1. **Fix source metadata** - 2h
270
+ - Update `enhanced_search.py` to normalize source extraction
271
+ - Add fallback chain for source field
272
+
273
+ 2. **Add batch ingestion** - 4h
274
+ - Create `POST /api/ingest/batch` endpoint
275
+ - Implement parallel encoding
276
+
277
+ 3. **Pre-load models** - 2h
278
+ - Move encoder initialization to app startup
279
+ - Add warmup request
280
+
281
+ ### This Week
282
+
283
+ 4. **Implement faceted search** - 4h
284
+ 5. **Add neighbors endpoint** - 3h
285
+ 6. **Research cross-modal alignment** - 4h
286
+
287
+ ### Next Week
288
+
289
+ 7. **Implement unified embedding space** - 16h
290
+ 8. **Add image modality** - 12h
291
+ 9. **Performance optimization** - 8h
292
+
293
+ ---
294
+
295
+ ## 6. Success Metrics
296
+
297
+ | Metric | Current | Target |
298
+ |--------|---------|--------|
299
+ | Test Pass Rate | 100% | 100% |
300
+ | Warning Count | 6 | 0 |
301
+ | Ingestion Speed | 0.4/sec | 10/sec |
302
+ | Search Latency | 2s | <500ms |
303
+ | Cross-Modal Recall | ~0% | >50% |
304
+ | Traceable Results | 40% | 100% |
305
+ | Supported Modalities | 3 | 5+ |
306
+
307
+ ---
308
+
309
+ ## 7. Risk Assessment
310
+
311
+ | Risk | Probability | Impact | Mitigation |
312
+ |------|-------------|--------|------------|
313
+ | Cross-modal alignment fails | Medium | High | Use separate collections per modality |
314
+ | Memory issues at scale | Medium | Medium | Implement streaming/pagination |
315
+ | Model loading too slow | Low | Medium | Use model registry with lazy loading |
316
+ | Qdrant performance | Low | Medium | Consider sharding for large datasets |
317
+
318
+ ---
319
+
320
+ ## Appendix: Test Report Summary
321
+
322
+ ```
323
+ 📊 STRESS TEST REPORT
324
+ ======================================================================
325
+ By Category:
326
+ ✅ ingestion: 3/3 passed, 0 warnings
327
+ ✅ search: 4/4 passed, 2 warnings
328
+ ✅ agents: 5/5 passed, 0 warnings
329
+ ✅ ui: 3/3 passed, 0 warnings
330
+ ✅ stress: 2/2 passed, 1 warnings
331
+ ✅ uc4: 4/4 passed, 3 warnings
332
+
333
+ Total: 21/21 tests passed
334
+ Warnings: 6
335
+ Duration: 130.8s
336
+ ```
337
+
338
+ ---
339
+
340
+ *Document generated: 2025-01-XX*
341
+ *BioFlow UC4 Evaluation v1.0*
bioflow/agents/workflow.py CHANGED
@@ -482,12 +482,65 @@ class DiscoveryWorkflow:
482
  enriched = []
483
  for r in ranked[:self.top_k]:
484
  smiles = r.get("smiles")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  enriched.append({
486
  **r,
487
- "generation": next(
488
- (c for c in result.context.candidates if c.get("smiles") == smiles),
489
- {}
490
- ),
 
 
491
  })
492
 
493
  return enriched
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  enriched = []
483
  for r in ranked[:self.top_k]:
484
  smiles = r.get("smiles")
485
+ candidate_ctx = next(
486
+ (c for c in result.context.candidates if c.get("smiles") == smiles),
487
+ {}
488
+ )
489
+
490
+ validation_ui = self._validation_to_ui(candidate_ctx.get("validation", {}))
491
+ name = (
492
+ candidate_ctx.get("name")
493
+ or candidate_ctx.get("title")
494
+ or candidate_ctx.get("label")
495
+ or r.get("name")
496
+ or f"Candidate {r.get('rank') or 0}"
497
+ )
498
+ score = r.get("final_score", r.get("score", 0.0))
499
  enriched.append({
500
  **r,
501
+ # UI-friendly fields
502
+ "name": name,
503
+ "score": score,
504
+ "validation": validation_ui,
505
+ # Keep full context for debugging / extended UI panels
506
+ "generation": candidate_ctx,
507
  })
508
 
509
  return enriched
510
+
511
+ def _validation_to_ui(self, validation: Any) -> Dict[str, Any]:
512
+ """
513
+ Normalize validator output to the UI-friendly shape:
514
+ { is_valid: bool, checks: {name: bool}, properties: {name: number} }.
515
+ """
516
+ if not isinstance(validation, dict):
517
+ return {"is_valid": True, "checks": {}, "properties": {}}
518
+
519
+ # Determine validity
520
+ if "is_valid" in validation:
521
+ is_valid = bool(validation.get("is_valid"))
522
+ else:
523
+ status = str(validation.get("status", "passed")).lower()
524
+ is_valid = status in ("passed", "ok", "success", "true")
525
+
526
+ checks: Dict[str, bool] = {}
527
+ properties: Dict[str, float] = {}
528
+
529
+ props = validation.get("properties", [])
530
+ if isinstance(props, list):
531
+ for p in props:
532
+ if not isinstance(p, dict):
533
+ continue
534
+ name = str(p.get("name") or "").strip()
535
+ if not name:
536
+ continue
537
+ checks[name] = bool(p.get("passed", True))
538
+ value = p.get("value")
539
+ if isinstance(value, (int, float)):
540
+ properties[name] = float(value)
541
+
542
+ alerts = validation.get("alerts", [])
543
+ if isinstance(alerts, list):
544
+ checks["no_alerts"] = len(alerts) == 0
545
+
546
+ return {"is_valid": is_valid, "checks": checks, "properties": properties}
bioflow/api/qdrant_service.py CHANGED
@@ -38,6 +38,7 @@ class SearchResult:
38
  content: str
39
  modality: str
40
  metadata: Dict[str, Any] = field(default_factory=dict)
 
41
 
42
 
43
  @dataclass
@@ -106,6 +107,13 @@ class QdrantService:
106
  self.url = url or os.getenv("QDRANT_URL")
107
  self.path = path or os.getenv("QDRANT_PATH", "./qdrant_data")
108
  self.vector_dim = vector_dim
 
 
 
 
 
 
 
109
 
110
  self._client = None
111
  self._initialized_collections: set = set()
@@ -140,6 +148,13 @@ class QdrantService:
140
  else:
141
  self._client = QdrantClient(path=self.path)
142
  logger.info(f"Using local Qdrant at {self.path}")
 
 
 
 
 
 
 
143
 
144
  return self._client
145
  except Exception as e:
@@ -148,6 +163,7 @@ class QdrantService:
148
  def _ensure_collection(self, collection: str):
149
  """Ensure collection exists."""
150
  if collection in self._initialized_collections:
 
151
  return
152
 
153
  client = self._get_client()
@@ -159,18 +175,83 @@ class QdrantService:
159
  exists = any(c.name == collection for c in collections)
160
 
161
  if not exists:
162
- client.create_collection(
163
- collection_name=collection,
164
- vectors_config=VectorParams(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  size=self.vector_dim,
166
- distance=Distance.COSINE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  )
168
- )
169
  logger.info(f"Created collection: {collection}")
 
170
  except Exception as e:
171
  raise QdrantServiceError(f"Failed to ensure collection {collection}: {e}")
172
 
173
  self._initialized_collections.add(collection)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  def _get_embedding(self, content: str, modality: str) -> List[float]:
176
  """Get embedding for content."""
@@ -228,14 +309,17 @@ class QdrantService:
228
 
229
  # Generate ID
230
  point_id = id or str(uuid.uuid4())
231
-
232
  # Get embedding - will raise if fails
233
  vector = self._get_embedding(content, modality)
234
-
 
 
 
235
  # Prepare payload
236
  payload = {
237
  "content": content,
238
- "modality": modality,
239
  **(metadata or {})
240
  }
241
 
@@ -299,7 +383,8 @@ class QdrantService:
299
  modality: str = "text",
300
  collection: str = None,
301
  limit: int = 10,
302
- filter_modality: str = None
 
303
  ) -> List[SearchResult]:
304
  """
305
  Semantic search across vectors.
@@ -324,14 +409,26 @@ class QdrantService:
324
  if collection:
325
  collections = [collection]
326
  else:
327
- collections = [c.value for c in CollectionType]
 
 
 
 
328
 
329
  client = self._get_client()
330
  all_results = []
 
 
 
 
 
 
331
 
332
  for coll in collections:
333
  try:
 
334
  if coll not in self._initialized_collections:
 
335
  continue
336
 
337
  filter_conditions = None
@@ -345,20 +442,41 @@ class QdrantService:
345
  )
346
 
347
  # Use query_points for newer qdrant-client versions
348
- results = client.query_points(
349
- collection_name=coll,
350
- query=query_vector,
351
- limit=limit,
352
- query_filter=filter_conditions
353
- ).points
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  for r in results:
 
 
 
 
 
 
 
356
  all_results.append(SearchResult(
357
  id=str(r.id),
358
  score=r.score,
359
  content=r.payload.get("content", ""),
360
- modality=r.payload.get("modality", "unknown"),
361
- metadata=r.payload
 
362
  ))
363
  except Exception as e:
364
  raise QdrantServiceError(f"Search in {coll} failed: {e}")
@@ -377,9 +495,86 @@ class QdrantService:
377
 
378
  try:
379
  collections = client.get_collections().collections
380
- return [c.name for c in collections]
 
 
381
  except Exception as e:
382
  raise QdrantServiceError(f"Failed to list collections: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
  def get_collection_stats(self, collection: str) -> Dict[str, Any]:
385
  """Get statistics for a collection."""
 
38
  content: str
39
  modality: str
40
  metadata: Dict[str, Any] = field(default_factory=dict)
41
+ vector: Optional[List[float]] = None
42
 
43
 
44
  @dataclass
 
107
  self.url = url or os.getenv("QDRANT_URL")
108
  self.path = path or os.getenv("QDRANT_PATH", "./qdrant_data")
109
  self.vector_dim = vector_dim
110
+ self.hnsw_m = int(os.getenv("QDRANT_HNSW_M", "16"))
111
+ self.hnsw_ef_construct = int(os.getenv("QDRANT_HNSW_EF_CONSTRUCT", "128"))
112
+ self.hnsw_ef = int(os.getenv("QDRANT_HNSW_EF", "128"))
113
+ self.optimizers_memmap_threshold = int(os.getenv("QDRANT_OPTIMIZERS_MEMMAP_THRESHOLD", "20000"))
114
+ self.optimizers_indexing_threshold = int(os.getenv("QDRANT_OPTIMIZERS_INDEXING_THRESHOLD", "20000"))
115
+ self.optimizers_flush_interval_sec = int(os.getenv("QDRANT_OPTIMIZERS_FLUSH_INTERVAL", "5"))
116
+ self.on_disk_payload = os.getenv("QDRANT_ON_DISK_PAYLOAD", "false").lower() in ("1", "true", "yes")
117
 
118
  self._client = None
119
  self._initialized_collections: set = set()
 
148
  else:
149
  self._client = QdrantClient(path=self.path)
150
  logger.info(f"Using local Qdrant at {self.path}")
151
+
152
+ # Populate known collections so searches work after process restart.
153
+ try:
154
+ collections = self._client.get_collections().collections
155
+ self._initialized_collections = {c.name for c in collections}
156
+ except Exception as e:
157
+ logger.warning(f"Failed to pre-load collections list: {e}")
158
 
159
  return self._client
160
  except Exception as e:
 
163
  def _ensure_collection(self, collection: str):
164
  """Ensure collection exists."""
165
  if collection in self._initialized_collections:
166
+ self._ensure_payload_indexes(collection)
167
  return
168
 
169
  client = self._get_client()
 
175
  exists = any(c.name == collection for c in collections)
176
 
177
  if not exists:
178
+ hnsw_config = None
179
+ optimizers_config = None
180
+ try:
181
+ from qdrant_client.models import HnswConfigDiff, OptimizersConfigDiff
182
+ hnsw_config = HnswConfigDiff(
183
+ m=self.hnsw_m,
184
+ ef_construct=self.hnsw_ef_construct,
185
+ )
186
+ optimizers_config = OptimizersConfigDiff(
187
+ memmap_threshold=self.optimizers_memmap_threshold,
188
+ indexing_threshold=self.optimizers_indexing_threshold,
189
+ flush_interval_sec=self.optimizers_flush_interval_sec,
190
+ )
191
+ except Exception as e:
192
+ logger.warning(f"Qdrant advanced configs unavailable: {e}")
193
+
194
+ create_kwargs = {
195
+ "collection_name": collection,
196
+ "vectors_config": VectorParams(
197
  size=self.vector_dim,
198
+ distance=Distance.COSINE,
199
+ ),
200
+ }
201
+ if hnsw_config is not None:
202
+ create_kwargs["hnsw_config"] = hnsw_config
203
+ if optimizers_config is not None:
204
+ create_kwargs["optimizers_config"] = optimizers_config
205
+ if self.on_disk_payload:
206
+ create_kwargs["on_disk_payload"] = True
207
+
208
+ try:
209
+ client.create_collection(**create_kwargs)
210
+ except TypeError:
211
+ client.create_collection(
212
+ collection_name=collection,
213
+ vectors_config=VectorParams(
214
+ size=self.vector_dim,
215
+ distance=Distance.COSINE,
216
+ ),
217
  )
 
218
  logger.info(f"Created collection: {collection}")
219
+ self._ensure_payload_indexes(collection)
220
  except Exception as e:
221
  raise QdrantServiceError(f"Failed to ensure collection {collection}: {e}")
222
 
223
  self._initialized_collections.add(collection)
224
+
225
+ def _ensure_payload_indexes(self, collection: str) -> None:
226
+ """Ensure payload indexes exist for common filter fields."""
227
+ client = self._get_client()
228
+
229
+ try:
230
+ from qdrant_client.models import PayloadSchemaType
231
+ except Exception as e:
232
+ logger.warning(f"Payload indexes unavailable: {e}")
233
+ return
234
+
235
+ index_specs = [
236
+ ("source", PayloadSchemaType.KEYWORD),
237
+ ("modality", PayloadSchemaType.KEYWORD),
238
+ ("organism", PayloadSchemaType.KEYWORD),
239
+ ("organism_id", PayloadSchemaType.KEYWORD),
240
+ ("year", PayloadSchemaType.INTEGER),
241
+ ("pmid", PayloadSchemaType.KEYWORD),
242
+ ("chembl_id", PayloadSchemaType.KEYWORD),
243
+ ("accession", PayloadSchemaType.KEYWORD),
244
+ ]
245
+
246
+ for field_name, field_schema in index_specs:
247
+ try:
248
+ client.create_payload_index(
249
+ collection_name=collection,
250
+ field_name=field_name,
251
+ field_schema=field_schema,
252
+ )
253
+ except Exception as e:
254
+ logger.debug(f"Payload index {field_name} not created: {e}")
255
 
256
  def _get_embedding(self, content: str, modality: str) -> List[float]:
257
  """Get embedding for content."""
 
309
 
310
  # Generate ID
311
  point_id = id or str(uuid.uuid4())
312
+
313
  # Get embedding - will raise if fails
314
  vector = self._get_embedding(content, modality)
315
+
316
+ # Normalize modality for downstream UI consistency
317
+ stored_modality = "molecule" if modality in ("smiles", "molecule") else modality
318
+
319
  # Prepare payload
320
  payload = {
321
  "content": content,
322
+ "modality": stored_modality,
323
  **(metadata or {})
324
  }
325
 
 
383
  modality: str = "text",
384
  collection: str = None,
385
  limit: int = 10,
386
+ filter_modality: str = None,
387
+ with_vectors: bool = False,
388
  ) -> List[SearchResult]:
389
  """
390
  Semantic search across vectors.
 
409
  if collection:
410
  collections = [collection]
411
  else:
412
+ # Search across all existing collections (not just the enum defaults).
413
+ try:
414
+ collections = self.list_collections()
415
+ except Exception:
416
+ collections = [c.value for c in CollectionType]
417
 
418
  client = self._get_client()
419
  all_results = []
420
+ search_params = None
421
+ try:
422
+ from qdrant_client.models import SearchParams
423
+ search_params = SearchParams(hnsw_ef=self.hnsw_ef)
424
+ except Exception:
425
+ search_params = None
426
 
427
  for coll in collections:
428
  try:
429
+ # Skip collections that don't exist (or are not accessible)
430
  if coll not in self._initialized_collections:
431
+ # If we populated from list_collections() this won't happen, but keep safe.
432
  continue
433
 
434
  filter_conditions = None
 
442
  )
443
 
444
  # Use query_points for newer qdrant-client versions
445
+ try:
446
+ results = client.query_points(
447
+ collection_name=coll,
448
+ query=query_vector,
449
+ limit=limit,
450
+ query_filter=filter_conditions,
451
+ search_params=search_params,
452
+ with_payload=True,
453
+ with_vectors=with_vectors,
454
+ ).points
455
+ except TypeError:
456
+ results = client.query_points(
457
+ collection_name=coll,
458
+ query=query_vector,
459
+ limit=limit,
460
+ query_filter=filter_conditions,
461
+ with_payload=True,
462
+ with_vectors=with_vectors,
463
+ ).points
464
 
465
  for r in results:
466
+ raw_modality = r.payload.get("modality", "unknown")
467
+ normalized_modality = "molecule" if raw_modality == "smiles" else raw_modality
468
+ vec = None
469
+ if with_vectors:
470
+ vec = r.vector
471
+ if isinstance(vec, dict):
472
+ vec = list(vec.values())[0] if vec else None
473
  all_results.append(SearchResult(
474
  id=str(r.id),
475
  score=r.score,
476
  content=r.payload.get("content", ""),
477
+ modality=normalized_modality,
478
+ metadata=r.payload,
479
+ vector=vec,
480
  ))
481
  except Exception as e:
482
  raise QdrantServiceError(f"Search in {coll} failed: {e}")
 
495
 
496
  try:
497
  collections = client.get_collections().collections
498
+ names = [c.name for c in collections]
499
+ self._initialized_collections = set(names)
500
+ return names
501
  except Exception as e:
502
  raise QdrantServiceError(f"Failed to list collections: {e}")
503
+
504
+ def list_items(
505
+ self,
506
+ collection: str,
507
+ limit: int = 20,
508
+ offset: int = 0,
509
+ filter_modality: Optional[str] = None,
510
+ ) -> List[SearchResult]:
511
+ """
512
+ List items from a collection.
513
+
514
+ Note: Qdrant pagination uses a point-id offset rather than numeric offsets.
515
+ For UI compatibility, we over-fetch `offset + limit` and slice.
516
+ """
517
+ client = self._get_client()
518
+
519
+ try:
520
+ existing = set(self.list_collections())
521
+ if collection not in existing:
522
+ return []
523
+
524
+ scroll_limit = max(1, offset + limit)
525
+
526
+ scroll_filter = None
527
+ if filter_modality:
528
+ from qdrant_client.models import Filter, FieldCondition, MatchValue
529
+ if filter_modality == "molecule":
530
+ # Support legacy stored value "smiles"
531
+ scroll_filter = Filter(
532
+ should=[
533
+ FieldCondition(key="modality", match=MatchValue(value="molecule")),
534
+ FieldCondition(key="modality", match=MatchValue(value="smiles")),
535
+ ]
536
+ )
537
+ else:
538
+ scroll_filter = Filter(
539
+ must=[FieldCondition(key="modality", match=MatchValue(value=filter_modality))]
540
+ )
541
+
542
+ # qdrant-client API has used both `scroll_filter=` and `filter=` across versions.
543
+ try:
544
+ points, _next = client.scroll(
545
+ collection_name=collection,
546
+ scroll_filter=scroll_filter,
547
+ limit=scroll_limit,
548
+ with_payload=True,
549
+ with_vectors=False,
550
+ )
551
+ except TypeError:
552
+ points, _next = client.scroll(
553
+ collection_name=collection,
554
+ filter=scroll_filter,
555
+ limit=scroll_limit,
556
+ with_payload=True,
557
+ with_vectors=False,
558
+ )
559
+
560
+ sliced = points[offset:offset + limit]
561
+ results: List[SearchResult] = []
562
+ for p in sliced:
563
+ payload = p.payload or {}
564
+ raw_modality = payload.get("modality", "unknown")
565
+ normalized_modality = "molecule" if raw_modality == "smiles" else raw_modality
566
+ results.append(
567
+ SearchResult(
568
+ id=str(p.id),
569
+ score=0.0,
570
+ content=payload.get("content", ""),
571
+ modality=normalized_modality,
572
+ metadata=payload,
573
+ )
574
+ )
575
+ return results
576
+ except Exception as e:
577
+ raise QdrantServiceError(f"Failed to list items in {collection}: {e}")
578
 
579
  def get_collection_stats(self, collection: str) -> Dict[str, Any]:
580
  """Get statistics for a collection."""
bioflow/api/server.py CHANGED
@@ -9,11 +9,17 @@ import os
9
  import sys
10
  import uuid
11
  import logging
 
 
 
 
 
12
  from datetime import datetime
13
  from typing import Any, Dict, List, Optional
14
  from contextlib import asynccontextmanager
15
 
16
  from fastapi import FastAPI, HTTPException, BackgroundTasks
 
17
  from fastapi.middleware.cors import CORSMiddleware
18
  from pydantic import BaseModel, Field
19
 
@@ -74,6 +80,48 @@ class IngestRequest(BaseModel):
74
  metadata: Optional[Dict[str, Any]] = None
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  class JobStatus(BaseModel):
78
  """Status of an async job."""
79
  job_id: str
@@ -95,7 +143,7 @@ class HealthResponse(BaseModel):
95
  class EnhancedSearchRequest(BaseModel):
96
  """Request for enhanced search with MMR and filters."""
97
  query: str = Field(..., description="Search query (text, SMILES, or protein sequence)")
98
- modality: str = Field(default="text", description="Query modality: text, molecule, protein")
99
  collection: Optional[str] = Field(default=None, description="Target collection")
100
  top_k: int = Field(default=20, ge=1, le=100)
101
  use_mmr: bool = Field(default=True, description="Apply MMR diversification")
@@ -187,6 +235,54 @@ async def health():
187
  )
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  # ============================================================================
191
  # Discovery Pipeline
192
  # ============================================================================
@@ -329,6 +425,20 @@ def get_predictor() -> DeepPurposePredictor:
329
  return _dti_predictor
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  @app.post("/api/predict")
333
  async def predict_dti(request: PredictRequest):
334
  """
@@ -354,7 +464,17 @@ async def predict_dti(request: PredictRequest):
354
  **result.metadata,
355
  }
356
  }
357
-
 
 
 
 
 
 
 
 
 
 
358
  except Exception as e:
359
  logger.error(f"Prediction failed: {e}")
360
  raise HTTPException(status_code=500, detail=str(e))
@@ -370,6 +490,8 @@ def get_enhanced_search_service():
370
  global _enhanced_search_service
371
  if _enhanced_search_service is None:
372
  from bioflow.search.enhanced_search import EnhancedSearchService
 
 
373
  encoder = model_service.get_obm_encoder()
374
  _enhanced_search_service = EnhancedSearchService(
375
  qdrant_service=qdrant_service,
@@ -378,6 +500,19 @@ def get_enhanced_search_service():
378
  return _enhanced_search_service
379
 
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  @app.post("/api/search")
382
  async def enhanced_search(request: EnhancedSearchRequest):
383
  """
@@ -389,6 +524,8 @@ async def enhanced_search(request: EnhancedSearchRequest):
389
  - Citations and source tracking
390
  - Filtered search by modality, source, etc.
391
  """
 
 
392
  try:
393
  if not qdrant_service:
394
  raise HTTPException(status_code=503, detail="Qdrant service not available")
@@ -404,9 +541,26 @@ async def enhanced_search(request: EnhancedSearchRequest):
404
  filters=request.filters,
405
  )
406
 
407
- return response.to_dict()
 
 
 
 
 
 
 
 
 
 
 
408
 
409
  except Exception as e:
 
 
 
 
 
 
410
  logger.error(f"Enhanced search failed: {e}")
411
  raise HTTPException(status_code=500, detail=str(e))
412
 
@@ -446,6 +600,8 @@ async def hybrid_search(request: HybridSearchRequest):
446
  @app.post("/api/ingest")
447
  async def ingest_data(request: IngestRequest):
448
  """Ingest data into vector database."""
 
 
449
  try:
450
  if not qdrant_service:
451
  raise HTTPException(status_code=503, detail="Qdrant service not available")
@@ -455,6 +611,12 @@ async def ingest_data(request: IngestRequest):
455
  modality=request.modality,
456
  metadata=request.metadata
457
  )
 
 
 
 
 
 
458
  return {
459
  "success": result.success,
460
  "id": result.id,
@@ -464,10 +626,171 @@ async def ingest_data(request: IngestRequest):
464
  }
465
 
466
  except Exception as e:
 
 
 
 
 
 
467
  logger.error(f"Ingest failed: {e}")
468
  raise HTTPException(status_code=500, detail=str(e))
469
 
470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  @app.get("/api/molecules")
472
  async def list_molecules(limit: int = 20, offset: int = 0):
473
  """List molecules in the database."""
@@ -475,23 +798,21 @@ async def list_molecules(limit: int = 20, offset: int = 0):
475
  raise HTTPException(status_code=503, detail="Qdrant service not available")
476
 
477
  try:
478
- results = qdrant_service.search(
479
- query="molecule",
480
- modality="text",
481
- collection="molecules",
482
- limit=limit
483
- )
484
  molecules = []
485
  for r in results:
 
486
  molecules.append({
487
  "id": r.id,
488
  "smiles": r.content,
489
- "name": r.metadata.get("name", "Unknown"),
490
- "mw": r.metadata.get("mw", 0),
 
 
491
  })
492
  return {
493
  "molecules": molecules,
494
- "total": len(molecules),
495
  "limit": limit,
496
  "offset": offset,
497
  }
@@ -507,24 +828,27 @@ async def list_proteins(limit: int = 20, offset: int = 0):
507
  raise HTTPException(status_code=503, detail="Qdrant service not available")
508
 
509
  try:
510
- results = qdrant_service.search(
511
- query="protein kinase receptor",
512
- modality="text",
513
- collection="proteins",
514
- limit=limit
515
- )
516
  proteins = []
517
  for r in results:
 
 
 
 
 
 
518
  proteins.append({
519
  "id": r.id,
520
  "sequence": r.content[:50] + "..." if len(r.content) > 50 else r.content,
521
- "uniprot_id": r.metadata.get("uniprot_id", ""),
522
- "name": r.metadata.get("name", "Unknown"),
 
 
523
  "length": len(r.content),
524
  })
525
  return {
526
  "proteins": proteins,
527
- "total": len(proteins),
528
  "limit": limit,
529
  "offset": offset,
530
  }
@@ -533,52 +857,482 @@ async def list_proteins(limit: int = 20, offset: int = 0):
533
  raise HTTPException(status_code=500, detail=str(e))
534
 
535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  # ============================================================================
537
  # Explorer (Embeddings)
538
  # ============================================================================
539
  @app.get("/api/explorer/embeddings")
540
- async def get_embeddings(dataset: str = "default", method: str = "umap"):
541
- """Get 2D projections of embeddings for visualization."""
 
 
 
 
 
 
542
  import numpy as np
543
-
544
  if not qdrant_service:
545
  raise HTTPException(status_code=503, detail="Qdrant service not available")
546
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  points = []
548
-
 
549
  try:
550
- # Get molecules and proteins from Qdrant
551
- mol_results = qdrant_service.search("", modality="text", collection="molecules", limit=50)
552
- prot_results = qdrant_service.search("", modality="text", collection="proteins", limit=50)
553
-
554
- all_results = mol_results + prot_results
555
-
556
- # Simple 2D projection using content hash for deterministic positions
557
- # (In production, use proper UMAP/t-SNE)
558
- for i, r in enumerate(all_results):
559
- np.random.seed(hash(r.content) % 2**32)
560
- cluster = 0 if r.modality == "molecule" else 1
561
- cx, cy = [(2, 3), (-2, -1)][cluster]
562
- points.append({
563
- "id": r.id,
564
- "x": float(cx + np.random.randn() * 0.8),
565
- "y": float(cy + np.random.randn() * 0.8),
566
- "cluster": cluster,
567
- "label": r.metadata.get("name", r.content[:20]),
568
- "modality": r.modality,
569
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  except Exception as e:
571
  logger.error(f"Failed to get embeddings from Qdrant: {e}")
572
  raise HTTPException(status_code=500, detail=str(e))
573
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  return {
575
  "points": points,
576
- "method": method,
577
  "dataset": dataset,
578
  "n_clusters": len(set(p["cluster"] for p in points)) if points else 0,
 
579
  }
580
 
581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  # ============================================================================
583
  # Additional API Endpoints
584
  # ============================================================================
@@ -811,6 +1565,8 @@ async def run_discovery_workflow(request: WorkflowRequest):
811
 
812
  Returns top candidates with all validation and ranking metadata.
813
  """
 
 
814
  try:
815
  from bioflow.agents import DiscoveryWorkflow
816
 
@@ -822,17 +1578,32 @@ async def run_discovery_workflow(request: WorkflowRequest):
822
  result = workflow.run(request.query)
823
  top_candidates = workflow.get_top_candidates(result)
824
 
825
- return {
826
  "success": result.status.value == "completed",
827
  "status": result.status.value,
828
  "steps_completed": result.steps_completed,
829
  "total_steps": result.total_steps,
830
  "execution_time_ms": result.execution_time_ms,
831
  "top_candidates": top_candidates,
 
832
  "all_outputs": result.outputs,
833
  "errors": result.errors,
834
  }
 
 
 
 
 
 
 
 
835
  except Exception as e:
 
 
 
 
 
 
836
  logger.error(f"Workflow failed: {e}")
837
  raise HTTPException(status_code=500, detail=str(e))
838
 
 
9
  import sys
10
  import uuid
11
  import logging
12
+ import json
13
+ import time
14
+ import json
15
+ import time
16
+ import requests
17
  from datetime import datetime
18
  from typing import Any, Dict, List, Optional
19
  from contextlib import asynccontextmanager
20
 
21
  from fastapi import FastAPI, HTTPException, BackgroundTasks
22
+ from fastapi.responses import PlainTextResponse
23
  from fastapi.middleware.cors import CORSMiddleware
24
  from pydantic import BaseModel, Field
25
 
 
80
  metadata: Optional[Dict[str, Any]] = None
81
 
82
 
83
+ class IngestSourceRequest(BaseModel):
84
+ """Request to ingest from a specific source."""
85
+ query: str
86
+ limit: int = Field(default=100, ge=1, le=10000)
87
+ batch_size: Optional[int] = Field(default=None, ge=1, le=1000)
88
+ rate_limit: Optional[float] = Field(default=None, ge=0.0)
89
+ collection: Optional[str] = Field(default="bioflow_memory")
90
+ sync: bool = Field(default=False, description="Run synchronously (may block)")
91
+ # PubMed-specific
92
+ email: Optional[str] = None
93
+ api_key: Optional[str] = None
94
+ # ChEMBL-specific
95
+ search_mode: Optional[str] = Field(default=None, description="target | molecule")
96
+
97
+
98
+ class BatchIngestRequest(BaseModel):
99
+ """Request to ingest multiple items at once."""
100
+ items: List[IngestRequest] = Field(..., description="List of items to ingest")
101
+ parallel: bool = Field(default=True, description="Process items in parallel")
102
+ batch_size: int = Field(default=10, ge=1, le=100, description="Batch size for parallel processing")
103
+
104
+
105
+ class IngestAllRequest(BaseModel):
106
+ """Request to ingest from all sources."""
107
+ query: str
108
+ pubmed_limit: int = Field(default=100, ge=0, le=10000)
109
+ uniprot_limit: int = Field(default=50, ge=0, le=10000)
110
+ chembl_limit: int = Field(default=30, ge=0, le=10000)
111
+ batch_size: Optional[int] = Field(default=None, ge=1, le=1000)
112
+ rate_limit: Optional[float] = Field(default=None, ge=0.0)
113
+ collection: Optional[str] = Field(default="bioflow_memory")
114
+ skip_pubmed: bool = False
115
+ skip_uniprot: bool = False
116
+ skip_chembl: bool = False
117
+ sync: bool = Field(default=False, description="Run synchronously (may block)")
118
+ # PubMed-specific
119
+ email: Optional[str] = None
120
+ api_key: Optional[str] = None
121
+ # ChEMBL-specific
122
+ search_mode: Optional[str] = Field(default=None, description="target | molecule")
123
+
124
+
125
  class JobStatus(BaseModel):
126
  """Status of an async job."""
127
  job_id: str
 
143
  class EnhancedSearchRequest(BaseModel):
144
  """Request for enhanced search with MMR and filters."""
145
  query: str = Field(..., description="Search query (text, SMILES, or protein sequence)")
146
+ modality: str = Field(default="auto", description="Query modality: auto, text, molecule, protein")
147
  collection: Optional[str] = Field(default=None, description="Target collection")
148
  top_k: int = Field(default=20, ge=1, le=100)
149
  use_mmr: bool = Field(default=True, description="Apply MMR diversification")
 
235
  )
236
 
237
 
238
+ @app.get("/api/health/metrics")
239
+ async def health_metrics():
240
+ """Detailed health metrics for Qdrant and model readiness."""
241
+ metrics = {
242
+ "status": "ok",
243
+ "timestamp": datetime.utcnow().isoformat(),
244
+ "qdrant": {"available": False, "collections": []},
245
+ "models": {"available": False, "device": None, "obm_loaded": False},
246
+ }
247
+
248
+ if qdrant_service:
249
+ try:
250
+ collections = qdrant_service.list_collections()
251
+ metrics["qdrant"]["available"] = True
252
+ metrics["qdrant"]["collections"] = collections
253
+ try:
254
+ client = qdrant_service._get_client()
255
+ collection_stats = {}
256
+ for name in collections:
257
+ try:
258
+ info = client.get_collection(name)
259
+ collection_stats[name] = {
260
+ "vectors_count": getattr(info, "vectors_count", None),
261
+ "points_count": getattr(info, "points_count", None),
262
+ }
263
+ except Exception:
264
+ collection_stats[name] = {}
265
+ metrics["qdrant"]["stats"] = collection_stats
266
+ except Exception:
267
+ metrics["qdrant"]["stats"] = {}
268
+ except Exception:
269
+ metrics["qdrant"]["available"] = False
270
+
271
+ if model_service:
272
+ try:
273
+ metrics["models"]["available"] = True
274
+ metrics["models"]["device"] = getattr(model_service, "device", None)
275
+ try:
276
+ _ = model_service.get_obm_encoder()
277
+ metrics["models"]["obm_loaded"] = True
278
+ except Exception:
279
+ metrics["models"]["obm_loaded"] = False
280
+ except Exception:
281
+ metrics["models"]["available"] = False
282
+
283
+ return metrics
284
+
285
+
286
  # ============================================================================
287
  # Discovery Pipeline
288
  # ============================================================================
 
425
  return _dti_predictor
426
 
427
 
428
+ def _fallback_dti_prediction(drug_smiles: str, target_sequence: str) -> Dict[str, Any]:
429
+ """Fallback prediction when DeepPurpose is unavailable."""
430
+ seed = abs(hash(drug_smiles + target_sequence)) % 1000
431
+ affinity = round(0.1 + (seed % 100) / 100.0, 4)
432
+ confidence = 0.2
433
+ return {
434
+ "drug_smiles": drug_smiles,
435
+ "target_sequence": target_sequence,
436
+ "binding_affinity": affinity,
437
+ "confidence": confidence,
438
+ "interaction_probability": min(confidence + 0.05, 1.0),
439
+ }
440
+
441
+
442
  @app.post("/api/predict")
443
  async def predict_dti(request: PredictRequest):
444
  """
 
464
  **result.metadata,
465
  }
466
  }
467
+ except ImportError:
468
+ fallback = _fallback_dti_prediction(request.drug_smiles, request.target_sequence)
469
+ return {
470
+ "success": True,
471
+ "prediction": fallback,
472
+ "metadata": {
473
+ "model": "fallback",
474
+ "timestamp": datetime.utcnow().isoformat(),
475
+ "note": "DeepPurpose not installed; using fallback prediction.",
476
+ },
477
+ }
478
  except Exception as e:
479
  logger.error(f"Prediction failed: {e}")
480
  raise HTTPException(status_code=500, detail=str(e))
 
490
  global _enhanced_search_service
491
  if _enhanced_search_service is None:
492
  from bioflow.search.enhanced_search import EnhancedSearchService
493
+ if not model_service:
494
+ raise HTTPException(status_code=503, detail="Model service not available")
495
  encoder = model_service.get_obm_encoder()
496
  _enhanced_search_service = EnhancedSearchService(
497
  qdrant_service=qdrant_service,
 
500
  return _enhanced_search_service
501
 
502
 
503
+ def _log_event(event: str, request_id: str, **fields: Any) -> None:
504
+ payload = {
505
+ "event": event,
506
+ "request_id": request_id,
507
+ "timestamp": datetime.utcnow().isoformat(),
508
+ **fields,
509
+ }
510
+ try:
511
+ logger.info(json.dumps(payload, ensure_ascii=False))
512
+ except Exception:
513
+ logger.info(f"{event} {payload}")
514
+
515
+
516
  @app.post("/api/search")
517
  async def enhanced_search(request: EnhancedSearchRequest):
518
  """
 
524
  - Citations and source tracking
525
  - Filtered search by modality, source, etc.
526
  """
527
+ request_id = uuid.uuid4().hex[:12]
528
+ start = time.perf_counter()
529
  try:
530
  if not qdrant_service:
531
  raise HTTPException(status_code=503, detail="Qdrant service not available")
 
541
  filters=request.filters,
542
  )
543
 
544
+ payload = response.to_dict()
545
+ _log_event(
546
+ "search",
547
+ request_id,
548
+ query=request.query[:200],
549
+ top_k=request.top_k,
550
+ use_mmr=request.use_mmr,
551
+ returned=payload.get("returned"),
552
+ total_found=payload.get("total_found"),
553
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
554
+ )
555
+ return payload
556
 
557
  except Exception as e:
558
+ _log_event(
559
+ "search_error",
560
+ request_id,
561
+ error=str(e),
562
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
563
+ )
564
  logger.error(f"Enhanced search failed: {e}")
565
  raise HTTPException(status_code=500, detail=str(e))
566
 
 
600
  @app.post("/api/ingest")
601
  async def ingest_data(request: IngestRequest):
602
  """Ingest data into vector database."""
603
+ request_id = uuid.uuid4().hex[:12]
604
+ start = time.perf_counter()
605
  try:
606
  if not qdrant_service:
607
  raise HTTPException(status_code=503, detail="Qdrant service not available")
 
611
  modality=request.modality,
612
  metadata=request.metadata
613
  )
614
+ _log_event(
615
+ "ingest_single",
616
+ request_id,
617
+ modality=request.modality,
618
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
619
+ )
620
  return {
621
  "success": result.success,
622
  "id": result.id,
 
626
  }
627
 
628
  except Exception as e:
629
+ _log_event(
630
+ "ingest_single_error",
631
+ request_id,
632
+ error=str(e),
633
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
634
+ )
635
  logger.error(f"Ingest failed: {e}")
636
  raise HTTPException(status_code=500, detail=str(e))
637
 
638
 
639
+ @app.post("/api/ingest/batch")
640
+ async def ingest_batch(request: BatchIngestRequest):
641
+ """
642
+ Batch ingest multiple items for improved performance.
643
+
644
+ Processes items in parallel (if parallel=True) to significantly
645
+ improve ingestion speed compared to sequential single-item ingestion.
646
+ """
647
+ import asyncio
648
+ import concurrent.futures
649
+
650
+ request_id = uuid.uuid4().hex[:12]
651
+ start = time.perf_counter()
652
+
653
+ if not qdrant_service:
654
+ raise HTTPException(status_code=503, detail="Qdrant service not available")
655
+
656
+ items = request.items
657
+ if not items:
658
+ return {"success": True, "ingested": 0, "failed": 0, "rate_per_sec": 0}
659
+
660
+ results = {"ingested": 0, "failed": 0, "ids": [], "errors": []}
661
+
662
+ def ingest_one(item: IngestRequest):
663
+ """Ingest a single item (for parallel execution)."""
664
+ try:
665
+ result = qdrant_service.ingest(
666
+ content=item.content,
667
+ modality=item.modality,
668
+ metadata=item.metadata
669
+ )
670
+ return {"success": result.success, "id": result.id, "error": None}
671
+ except Exception as e:
672
+ return {"success": False, "id": None, "error": str(e)}
673
+
674
+ try:
675
+ if request.parallel:
676
+ # Process in batches using thread pool
677
+ batch_size = min(request.batch_size, len(items))
678
+ with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as executor:
679
+ futures = [executor.submit(ingest_one, item) for item in items]
680
+ for future in concurrent.futures.as_completed(futures):
681
+ res = future.result()
682
+ if res["success"]:
683
+ results["ingested"] += 1
684
+ if res["id"]:
685
+ results["ids"].append(res["id"])
686
+ else:
687
+ results["failed"] += 1
688
+ if res["error"]:
689
+ results["errors"].append(res["error"])
690
+ else:
691
+ # Sequential processing
692
+ for item in items:
693
+ res = ingest_one(item)
694
+ if res["success"]:
695
+ results["ingested"] += 1
696
+ if res["id"]:
697
+ results["ids"].append(res["id"])
698
+ else:
699
+ results["failed"] += 1
700
+ if res["error"]:
701
+ results["errors"].append(res["error"])
702
+
703
+ duration_s = time.perf_counter() - start
704
+ rate = results["ingested"] / duration_s if duration_s > 0 else 0
705
+
706
+ _log_event(
707
+ "ingest_batch",
708
+ request_id,
709
+ count=len(items),
710
+ ingested=results["ingested"],
711
+ failed=results["failed"],
712
+ rate_per_sec=round(rate, 2),
713
+ duration_ms=round(duration_s * 1000, 2),
714
+ )
715
+
716
+ return {
717
+ "success": results["failed"] == 0,
718
+ "ingested": results["ingested"],
719
+ "failed": results["failed"],
720
+ "ids": results["ids"][:50], # Limit response size
721
+ "rate_per_sec": round(rate, 2),
722
+ "duration_ms": round(duration_s * 1000, 2),
723
+ "errors": results["errors"][:10] if results["errors"] else None,
724
+ }
725
+
726
+ except Exception as e:
727
+ _log_event(
728
+ "ingest_batch_error",
729
+ request_id,
730
+ error=str(e),
731
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
732
+ )
733
+ logger.error(f"Batch ingest failed: {e}")
734
+ raise HTTPException(status_code=500, detail=str(e))
735
+
736
+
737
+ def _list_items_by_modality(modality: str, limit: int, offset: int):
738
+ """List items across all collections by modality."""
739
+ if not qdrant_service:
740
+ return [], 0
741
+
742
+ collections = []
743
+ try:
744
+ collections = qdrant_service.list_collections()
745
+ except Exception:
746
+ collections = ["molecules", "proteins", "bioflow_memory"]
747
+
748
+ items: List[Any] = []
749
+ target = offset + limit
750
+ for coll in collections:
751
+ if len(items) >= target:
752
+ break
753
+ try:
754
+ items.extend(
755
+ qdrant_service.list_items(
756
+ collection=coll,
757
+ limit=target,
758
+ offset=0,
759
+ filter_modality=modality,
760
+ )
761
+ )
762
+ except Exception:
763
+ continue
764
+
765
+ total = len(items)
766
+ return items[offset:offset + limit], total
767
+
768
+
769
+ def _find_point_by_id(point_id: str):
770
+ """Find a point by ID across collections."""
771
+ if not qdrant_service:
772
+ return None, None
773
+ client = qdrant_service._get_client()
774
+ try:
775
+ collections = qdrant_service.list_collections()
776
+ except Exception:
777
+ collections = ["molecules", "proteins", "bioflow_memory"]
778
+
779
+ for coll in collections:
780
+ try:
781
+ points = client.retrieve(
782
+ collection_name=coll,
783
+ ids=[point_id],
784
+ with_payload=True,
785
+ with_vectors=False,
786
+ )
787
+ if points:
788
+ return coll, points[0]
789
+ except Exception:
790
+ continue
791
+ return None, None
792
+
793
+
794
  @app.get("/api/molecules")
795
  async def list_molecules(limit: int = 20, offset: int = 0):
796
  """List molecules in the database."""
 
798
  raise HTTPException(status_code=503, detail="Qdrant service not available")
799
 
800
  try:
801
+ results, total = _list_items_by_modality("molecule", limit, offset)
 
 
 
 
 
802
  molecules = []
803
  for r in results:
804
+ metadata = r.metadata or {}
805
  molecules.append({
806
  "id": r.id,
807
  "smiles": r.content,
808
+ "name": metadata.get("name", metadata.get("title", "Unknown")),
809
+ "pubchemCid": metadata.get("pubchem_cid", metadata.get("pubchemCid", metadata.get("cid", 0))),
810
+ "description": metadata.get("description", metadata.get("title", "")),
811
+ "mw": metadata.get("mw", metadata.get("molecular_weight", 0)),
812
  })
813
  return {
814
  "molecules": molecules,
815
+ "total": total,
816
  "limit": limit,
817
  "offset": offset,
818
  }
 
828
  raise HTTPException(status_code=503, detail="Qdrant service not available")
829
 
830
  try:
831
+ results, total = _list_items_by_modality("protein", limit, offset)
 
 
 
 
 
832
  proteins = []
833
  for r in results:
834
+ metadata = r.metadata or {}
835
+ pdb_ids = metadata.get("pdb_ids", []) or []
836
+ if isinstance(pdb_ids, str):
837
+ pdb_ids = [pdb_ids]
838
+ pdb_id = metadata.get("pdb_id") or (pdb_ids[0] if pdb_ids else "")
839
+ name = metadata.get("name") or metadata.get("protein_name") or metadata.get("entry_name") or "Unknown"
840
  proteins.append({
841
  "id": r.id,
842
  "sequence": r.content[:50] + "..." if len(r.content) > 50 else r.content,
843
+ "uniprot_id": metadata.get("uniprot_id", metadata.get("accession", "")),
844
+ "name": name,
845
+ "pdbId": pdb_id,
846
+ "description": metadata.get("function", metadata.get("description", "")),
847
  "length": len(r.content),
848
  })
849
  return {
850
  "proteins": proteins,
851
+ "total": total,
852
  "limit": limit,
853
  "offset": offset,
854
  }
 
857
  raise HTTPException(status_code=500, detail=str(e))
858
 
859
 
860
+ @app.get("/api/molecules/{molecule_id}")
861
+ async def get_molecule(molecule_id: str):
862
+ """Get molecule details by ID."""
863
+ if not qdrant_service:
864
+ raise HTTPException(status_code=503, detail="Qdrant service not available")
865
+
866
+ _coll, point = _find_point_by_id(molecule_id)
867
+ if point is None:
868
+ raise HTTPException(status_code=404, detail="Molecule not found")
869
+
870
+ payload = point.payload or {}
871
+ smiles = payload.get("smiles") or payload.get("content", "")
872
+ return {
873
+ "id": str(point.id),
874
+ "name": payload.get("name", payload.get("title", "Unknown")),
875
+ "smiles": smiles,
876
+ "pubchemCid": payload.get("pubchem_cid", payload.get("pubchemCid", payload.get("cid", 0))),
877
+ "description": payload.get("description", payload.get("title", "")),
878
+ }
879
+
880
+
881
+ @app.get("/api/molecules/{molecule_id}/sdf")
882
+ async def get_molecule_sdf(molecule_id: str):
883
+ """Get molecule 3D structure as SDF."""
884
+ if not qdrant_service:
885
+ raise HTTPException(status_code=503, detail="Qdrant service not available")
886
+
887
+ _coll, point = _find_point_by_id(molecule_id)
888
+ if point is None:
889
+ raise HTTPException(status_code=404, detail="Molecule not found")
890
+
891
+ payload = point.payload or {}
892
+ smiles = payload.get("smiles") or payload.get("content", "")
893
+ if not smiles:
894
+ raise HTTPException(status_code=404, detail="Molecule SMILES not available")
895
+
896
+ try:
897
+ from rdkit import Chem
898
+ from rdkit.Chem import AllChem
899
+ except Exception:
900
+ raise HTTPException(status_code=503, detail="RDKit is required for 3D structures")
901
+
902
+ mol = Chem.MolFromSmiles(smiles)
903
+ if mol is None:
904
+ raise HTTPException(status_code=400, detail="Invalid SMILES")
905
+
906
+ mol = Chem.AddHs(mol)
907
+ try:
908
+ AllChem.EmbedMolecule(mol, AllChem.ETKDG())
909
+ AllChem.UFFOptimizeMolecule(mol)
910
+ except Exception:
911
+ pass
912
+ sdf = Chem.MolToMolBlock(mol)
913
+ return PlainTextResponse(sdf, media_type="chemical/x-mdl-sdfile")
914
+
915
+
916
+ @app.get("/api/proteins/{protein_id}")
917
+ async def get_protein(protein_id: str):
918
+ """Get protein details by ID."""
919
+ if not qdrant_service:
920
+ raise HTTPException(status_code=503, detail="Qdrant service not available")
921
+
922
+ _coll, point = _find_point_by_id(protein_id)
923
+ if point is None:
924
+ raise HTTPException(status_code=404, detail="Protein not found")
925
+
926
+ payload = point.payload or {}
927
+ pdb_ids = payload.get("pdb_ids", []) or []
928
+ if isinstance(pdb_ids, str):
929
+ pdb_ids = [pdb_ids]
930
+ pdb_id = payload.get("pdb_id") or (pdb_ids[0] if pdb_ids else "")
931
+ name = payload.get("name") or payload.get("protein_name") or payload.get("entry_name") or "Unknown"
932
+
933
+ return {
934
+ "id": str(point.id),
935
+ "pdbId": pdb_id,
936
+ "name": name,
937
+ "description": payload.get("function", payload.get("description", "")),
938
+ }
939
+
940
+
941
+ @app.get("/api/proteins/{protein_id}/pdb")
942
+ async def get_protein_pdb(protein_id: str):
943
+ """Get protein structure as PDB text."""
944
+ if not qdrant_service:
945
+ raise HTTPException(status_code=503, detail="Qdrant service not available")
946
+
947
+ _coll, point = _find_point_by_id(protein_id)
948
+ if point is None:
949
+ raise HTTPException(status_code=404, detail="Protein not found")
950
+
951
+ payload = point.payload or {}
952
+ pdb_ids = payload.get("pdb_ids", []) or []
953
+ if isinstance(pdb_ids, str):
954
+ pdb_ids = [pdb_ids]
955
+ pdb_id = payload.get("pdb_id") or (pdb_ids[0] if pdb_ids else "")
956
+ if not pdb_id:
957
+ raise HTTPException(status_code=404, detail="No PDB ID available for this protein")
958
+
959
+ try:
960
+ pdb_url = f"https://files.rcsb.org/download/{pdb_id}.pdb"
961
+ resp = requests.get(pdb_url, timeout=20)
962
+ if resp.status_code != 200:
963
+ raise HTTPException(status_code=404, detail="PDB file not found")
964
+ return PlainTextResponse(resp.text, media_type="chemical/x-pdb")
965
+ except HTTPException:
966
+ raise
967
+ except Exception as e:
968
+ raise HTTPException(status_code=500, detail=str(e))
969
+
970
+
971
  # ============================================================================
972
  # Explorer (Embeddings)
973
  # ============================================================================
974
  @app.get("/api/explorer/embeddings")
975
+ async def get_embeddings(
976
+ dataset: str = "default",
977
+ method: str = "pca",
978
+ query: Optional[str] = None,
979
+ modality: str = "auto",
980
+ limit: int = 100,
981
+ ):
982
+ """Get 3D projections of embeddings for visualization."""
983
  import numpy as np
984
+
985
  if not qdrant_service:
986
  raise HTTPException(status_code=503, detail="Qdrant service not available")
987
+
988
+ def _detect_modality(q: str) -> str:
989
+ if not q:
990
+ return "text"
991
+ seq = q.replace("\n", "").replace(" ", "")
992
+ if len(seq) >= 25 and all(c.upper() in "ACDEFGHIKLMNPQRSTVWYBXZJUO" for c in seq[:25]):
993
+ return "protein"
994
+ if any(c in q for c in "[]()=#@") and any(c.isalpha() for c in q):
995
+ return "molecule"
996
+ return "text"
997
+
998
+ def _cluster_for_modality(m: str) -> int:
999
+ if m == "molecule":
1000
+ return 0
1001
+ if m == "protein":
1002
+ return 1
1003
+ return 2
1004
+
1005
  points = []
1006
+ vectors = []
1007
+
1008
  try:
1009
+ if query:
1010
+ effective_modality = modality if modality != "auto" else _detect_modality(query)
1011
+ search_results = qdrant_service.search(
1012
+ query=query,
1013
+ modality=effective_modality,
1014
+ limit=min(limit, 500),
1015
+ with_vectors=True,
1016
+ )
1017
+ for r in search_results:
1018
+ if r.vector is None:
1019
+ continue
1020
+ vectors.append(r.vector)
1021
+ points.append({
1022
+ "id": r.id,
1023
+ "label": r.metadata.get("name", r.content[:40]),
1024
+ "content": r.content,
1025
+ "modality": r.modality,
1026
+ "source": r.metadata.get("source", "unknown"),
1027
+ "score": r.score,
1028
+ "cluster": _cluster_for_modality(r.modality),
1029
+ "metadata": r.metadata,
1030
+ })
1031
+ else:
1032
+ client = qdrant_service._get_client()
1033
+ collections = []
1034
+ try:
1035
+ collections = qdrant_service.list_collections()
1036
+ except Exception:
1037
+ collections = ["molecules", "proteins", "bioflow_memory"]
1038
+
1039
+ per_collection = max(10, int(limit / max(1, len(collections))))
1040
+ for coll in collections:
1041
+ try:
1042
+ res, _ = client.scroll(
1043
+ collection_name=coll,
1044
+ limit=per_collection,
1045
+ with_payload=True,
1046
+ with_vectors=True,
1047
+ )
1048
+ for p in res:
1049
+ vec = p.vector
1050
+ if isinstance(vec, dict):
1051
+ vec = list(vec.values())[0] if vec else None
1052
+ if vec is None:
1053
+ continue
1054
+ payload = p.payload or {}
1055
+ modality_val = payload.get("modality", "text")
1056
+ if modality_val == "smiles":
1057
+ modality_val = "molecule"
1058
+ vectors.append(vec)
1059
+ points.append({
1060
+ "id": str(p.id),
1061
+ "label": payload.get("name", payload.get("content", "")[:40]),
1062
+ "content": payload.get("content", ""),
1063
+ "modality": modality_val,
1064
+ "source": payload.get("source", "unknown"),
1065
+ "score": payload.get("score", 0),
1066
+ "cluster": _cluster_for_modality(modality_val),
1067
+ "metadata": payload,
1068
+ })
1069
+ except Exception as e:
1070
+ logger.warning(f"Failed to scroll collection {coll}: {e}")
1071
  except Exception as e:
1072
  logger.error(f"Failed to get embeddings from Qdrant: {e}")
1073
  raise HTTPException(status_code=500, detail=str(e))
1074
+
1075
+ # If we have vectors, compute projection (fallback to deterministic positions on failure)
1076
+ coords = []
1077
+ method_used = method.lower()
1078
+ if vectors and len(vectors) >= 2:
1079
+ try:
1080
+ if method_used not in ("pca", "umap", "tsne"):
1081
+ method_used = "pca"
1082
+ arr = np.array(vectors, dtype=float)
1083
+
1084
+ if method_used == "umap":
1085
+ try:
1086
+ import umap # type: ignore
1087
+ reducer = umap.UMAP(n_components=3, random_state=42)
1088
+ coords = reducer.fit_transform(arr).tolist()
1089
+ except Exception as e:
1090
+ logger.warning(f"UMAP unavailable, falling back to PCA: {e}")
1091
+ method_used = "pca"
1092
+
1093
+ if method_used == "tsne":
1094
+ from sklearn.manifold import TSNE
1095
+ perplexity = min(30, max(5, int(len(arr) / 3)))
1096
+ tsne = TSNE(n_components=3, init="random", learning_rate="auto", perplexity=perplexity)
1097
+ coords = tsne.fit_transform(arr).tolist()
1098
+
1099
+ if method_used == "pca":
1100
+ from sklearn.decomposition import PCA
1101
+ pca = PCA(n_components=3)
1102
+ coords = pca.fit_transform(arr).tolist()
1103
+ except Exception as e:
1104
+ logger.warning(f"Projection failed, using fallback: {e}")
1105
+ method_used = "fallback"
1106
+
1107
+ if not coords:
1108
+ coords = []
1109
+ for p in points:
1110
+ np.random.seed(hash(p["id"]) % 2**32)
1111
+ cx, cy, cz = [(2, 3, 1), (-2, -1, -1), (1, -3, 0)][p["cluster"] % 3]
1112
+ coords.append([
1113
+ float(cx + np.random.randn() * 0.6),
1114
+ float(cy + np.random.randn() * 0.6),
1115
+ float(cz + np.random.randn() * 0.6),
1116
+ ])
1117
+ method_used = "fallback"
1118
+
1119
+ for p, c in zip(points, coords):
1120
+ p["x"], p["y"], p["z"] = float(c[0]), float(c[1]), float(c[2])
1121
+
1122
+ avg_score = float(np.mean([p.get("score", 0) for p in points])) if points else 0.0
1123
+
1124
  return {
1125
  "points": points,
1126
+ "method": method_used,
1127
  "dataset": dataset,
1128
  "n_clusters": len(set(p["cluster"] for p in points)) if points else 0,
1129
+ "avg_score": avg_score,
1130
  }
1131
 
1132
 
1133
+ # ============================================================================
1134
+ # Source Ingestion (Phase 3)
1135
+ # ============================================================================
1136
+ def _resolve_batch_size(requested: Optional[int]) -> int:
1137
+ if requested is not None:
1138
+ return requested
1139
+ env_val = os.getenv("INGEST_BATCH_SIZE")
1140
+ if env_val:
1141
+ try:
1142
+ return int(env_val)
1143
+ except ValueError:
1144
+ pass
1145
+ return 50
1146
+
1147
+
1148
+ def _resolve_rate_limit(source: str, requested: Optional[float]) -> float:
1149
+ if requested is not None:
1150
+ return requested
1151
+ env_key = f"{source.upper()}_RATE_LIMIT"
1152
+ env_val = os.getenv(env_key)
1153
+ if env_val:
1154
+ try:
1155
+ return float(env_val)
1156
+ except ValueError:
1157
+ pass
1158
+ defaults = {"pubmed": 0.4, "uniprot": 0.2, "chembl": 0.3}
1159
+ return defaults.get(source, 0.3)
1160
+
1161
+
1162
+ def _execute_source_ingestion(source: str, request: IngestSourceRequest):
1163
+ if not model_service or not qdrant_service:
1164
+ raise HTTPException(status_code=503, detail="Services not available")
1165
+
1166
+ encoder = model_service.get_obm_encoder()
1167
+ batch_size = _resolve_batch_size(request.batch_size)
1168
+ rate_limit = _resolve_rate_limit(source, request.rate_limit)
1169
+ collection = request.collection or "bioflow_memory"
1170
+
1171
+ if source == "pubmed":
1172
+ from bioflow.ingestion.pubmed_ingestor import PubMedIngestor
1173
+ ingestor = PubMedIngestor(
1174
+ qdrant_service=qdrant_service,
1175
+ obm_encoder=encoder,
1176
+ collection=collection,
1177
+ batch_size=batch_size,
1178
+ rate_limit=rate_limit,
1179
+ email=request.email or os.getenv("NCBI_EMAIL", "bioflow@example.com"),
1180
+ api_key=request.api_key or os.getenv("NCBI_API_KEY"),
1181
+ )
1182
+ return ingestor.ingest(request.query, request.limit)
1183
+
1184
+ if source == "uniprot":
1185
+ from bioflow.ingestion.uniprot_ingestor import UniProtIngestor
1186
+ ingestor = UniProtIngestor(
1187
+ qdrant_service=qdrant_service,
1188
+ obm_encoder=encoder,
1189
+ collection=collection,
1190
+ batch_size=batch_size,
1191
+ rate_limit=rate_limit,
1192
+ )
1193
+ return ingestor.ingest(request.query, request.limit)
1194
+
1195
+ if source == "chembl":
1196
+ from bioflow.ingestion.chembl_ingestor import ChEMBLIngestor
1197
+ ingestor = ChEMBLIngestor(
1198
+ qdrant_service=qdrant_service,
1199
+ obm_encoder=encoder,
1200
+ collection=collection,
1201
+ batch_size=batch_size,
1202
+ rate_limit=rate_limit,
1203
+ search_mode=request.search_mode or os.getenv("CHEMBL_SEARCH_MODE", "target"),
1204
+ )
1205
+ return ingestor.ingest(request.query, request.limit)
1206
+
1207
+ raise HTTPException(status_code=400, detail=f"Unknown source: {source}")
1208
+
1209
+
1210
+ def _start_ingestion_job(source: str, payload: Dict[str, Any], background_tasks: BackgroundTasks):
1211
+ job_id = f"ing_{uuid.uuid4().hex[:12]}"
1212
+ now = datetime.utcnow().isoformat()
1213
+ JOBS[job_id] = {
1214
+ "job_id": job_id,
1215
+ "type": "ingestion",
1216
+ "source": source,
1217
+ "status": "pending",
1218
+ "progress": 0,
1219
+ "result": None,
1220
+ "error": None,
1221
+ "created_at": now,
1222
+ "updated_at": now,
1223
+ "request": payload,
1224
+ }
1225
+
1226
+ background_tasks.add_task(_run_ingestion_job, job_id, source, payload)
1227
+ return job_id
1228
+
1229
+
1230
+ def _execute_all_ingestion(request: IngestAllRequest):
1231
+ results = {}
1232
+ if not request.skip_pubmed:
1233
+ results["pubmed"] = _execute_source_ingestion(
1234
+ "pubmed",
1235
+ IngestSourceRequest(
1236
+ query=request.query,
1237
+ limit=request.pubmed_limit,
1238
+ batch_size=request.batch_size,
1239
+ rate_limit=request.rate_limit,
1240
+ collection=request.collection,
1241
+ email=request.email,
1242
+ api_key=request.api_key,
1243
+ ),
1244
+ )
1245
+ if not request.skip_uniprot:
1246
+ results["uniprot"] = _execute_source_ingestion(
1247
+ "uniprot",
1248
+ IngestSourceRequest(
1249
+ query=request.query,
1250
+ limit=request.uniprot_limit,
1251
+ batch_size=request.batch_size,
1252
+ rate_limit=request.rate_limit,
1253
+ collection=request.collection,
1254
+ ),
1255
+ )
1256
+ if not request.skip_chembl:
1257
+ results["chembl"] = _execute_source_ingestion(
1258
+ "chembl",
1259
+ IngestSourceRequest(
1260
+ query=request.query,
1261
+ limit=request.chembl_limit,
1262
+ batch_size=request.batch_size,
1263
+ rate_limit=request.rate_limit,
1264
+ collection=request.collection,
1265
+ search_mode=request.search_mode,
1266
+ ),
1267
+ )
1268
+ return results
1269
+
1270
+
1271
+ def _run_ingestion_job(job_id: str, source: str, payload: Dict[str, Any]) -> None:
1272
+ JOBS[job_id]["status"] = "running"
1273
+ JOBS[job_id]["updated_at"] = datetime.utcnow().isoformat()
1274
+ try:
1275
+ if source == "all":
1276
+ results = _execute_all_ingestion(IngestAllRequest(**payload))
1277
+ JOBS[job_id]["result"] = {k: v.to_dict() for k, v in results.items()}
1278
+ else:
1279
+ req = IngestSourceRequest(**payload)
1280
+ result = _execute_source_ingestion(source, req)
1281
+ JOBS[job_id]["result"] = result.to_dict()
1282
+
1283
+ JOBS[job_id]["status"] = "completed"
1284
+ JOBS[job_id]["progress"] = 100
1285
+ JOBS[job_id]["updated_at"] = datetime.utcnow().isoformat()
1286
+ except Exception as e:
1287
+ JOBS[job_id]["status"] = "failed"
1288
+ JOBS[job_id]["error"] = str(e)
1289
+ JOBS[job_id]["updated_at"] = datetime.utcnow().isoformat()
1290
+ logger.error(f"Ingestion job failed: {e}")
1291
+
1292
+
1293
+ @app.post("/api/ingest/pubmed")
1294
+ async def ingest_pubmed(request: IngestSourceRequest, background_tasks: BackgroundTasks):
1295
+ if request.sync:
1296
+ result = _execute_source_ingestion("pubmed", request)
1297
+ return {"success": True, "result": result.to_dict()}
1298
+ job_id = _start_ingestion_job("pubmed", request.model_dump(), background_tasks)
1299
+ return {"success": True, "job_id": job_id, "status": "pending"}
1300
+
1301
+
1302
+ @app.post("/api/ingest/uniprot")
1303
+ async def ingest_uniprot(request: IngestSourceRequest, background_tasks: BackgroundTasks):
1304
+ if request.sync:
1305
+ result = _execute_source_ingestion("uniprot", request)
1306
+ return {"success": True, "result": result.to_dict()}
1307
+ job_id = _start_ingestion_job("uniprot", request.model_dump(), background_tasks)
1308
+ return {"success": True, "job_id": job_id, "status": "pending"}
1309
+
1310
+
1311
+ @app.post("/api/ingest/chembl")
1312
+ async def ingest_chembl(request: IngestSourceRequest, background_tasks: BackgroundTasks):
1313
+ if request.sync:
1314
+ result = _execute_source_ingestion("chembl", request)
1315
+ return {"success": True, "result": result.to_dict()}
1316
+ job_id = _start_ingestion_job("chembl", request.model_dump(), background_tasks)
1317
+ return {"success": True, "job_id": job_id, "status": "pending"}
1318
+
1319
+
1320
+ @app.post("/api/ingest/all")
1321
+ async def ingest_all(request: IngestAllRequest, background_tasks: BackgroundTasks):
1322
+ if request.sync:
1323
+ results = _execute_all_ingestion(request)
1324
+ return {"success": True, "result": {k: v.to_dict() for k, v in results.items()}}
1325
+ job_id = _start_ingestion_job("all", request.model_dump(), background_tasks)
1326
+ return {"success": True, "job_id": job_id, "status": "pending"}
1327
+
1328
+
1329
+ @app.get("/api/ingest/jobs/{job_id}")
1330
+ async def get_ingest_status(job_id: str):
1331
+ if job_id not in JOBS:
1332
+ raise HTTPException(status_code=404, detail="Job not found")
1333
+ return JOBS[job_id]
1334
+
1335
+
1336
  # ============================================================================
1337
  # Additional API Endpoints
1338
  # ============================================================================
 
1565
 
1566
  Returns top candidates with all validation and ranking metadata.
1567
  """
1568
+ request_id = uuid.uuid4().hex[:12]
1569
+ start = time.perf_counter()
1570
  try:
1571
  from bioflow.agents import DiscoveryWorkflow
1572
 
 
1578
  result = workflow.run(request.query)
1579
  top_candidates = workflow.get_top_candidates(result)
1580
 
1581
+ payload = {
1582
  "success": result.status.value == "completed",
1583
  "status": result.status.value,
1584
  "steps_completed": result.steps_completed,
1585
  "total_steps": result.total_steps,
1586
  "execution_time_ms": result.execution_time_ms,
1587
  "top_candidates": top_candidates,
1588
+ "candidates": top_candidates,
1589
  "all_outputs": result.outputs,
1590
  "errors": result.errors,
1591
  }
1592
+ _log_event(
1593
+ "workflow",
1594
+ request_id,
1595
+ status=payload.get("status"),
1596
+ steps_completed=payload.get("steps_completed"),
1597
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
1598
+ )
1599
+ return payload
1600
  except Exception as e:
1601
+ _log_event(
1602
+ "workflow_error",
1603
+ request_id,
1604
+ error=str(e),
1605
+ duration_ms=round((time.perf_counter() - start) * 1000, 2),
1606
+ )
1607
  logger.error(f"Workflow failed: {e}")
1608
  raise HTTPException(status_code=500, detail=str(e))
1609
 
bioflow/app.py DELETED
@@ -1,569 +0,0 @@
1
- """
2
- BioFlow Explorer - Streamlit Interface
3
- =======================================
4
-
5
- Interactive web interface for testing and exploring the BioFlow
6
- multimodal biological intelligence system.
7
-
8
- Run with: streamlit run bioflow/app.py
9
- """
10
-
11
- import streamlit as st
12
- import numpy as np
13
- import pandas as pd
14
- from typing import List, Dict, Any
15
- import json
16
- import os
17
- import sys
18
-
19
- # Add project root to path
20
- ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
- sys.path.insert(0, ROOT_DIR)
22
-
23
- # Page config
24
- st.set_page_config(
25
- page_title="BioFlow Explorer",
26
- page_icon="🧬",
27
- layout="wide",
28
- initial_sidebar_state="expanded"
29
- )
30
-
31
- # Custom CSS
32
- st.markdown("""
33
- <style>
34
- .main-header {
35
- font-size: 2.5rem;
36
- font-weight: bold;
37
- background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
38
- -webkit-background-clip: text;
39
- -webkit-text-fill-color: transparent;
40
- margin-bottom: 1rem;
41
- }
42
- .metric-card {
43
- background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
44
- padding: 1rem;
45
- border-radius: 0.5rem;
46
- margin: 0.5rem 0;
47
- }
48
- .result-card {
49
- border: 1px solid #ddd;
50
- border-radius: 0.5rem;
51
- padding: 1rem;
52
- margin: 0.5rem 0;
53
- background: white;
54
- }
55
- .modality-text { color: #3b82f6; }
56
- .modality-molecule { color: #10b981; }
57
- .modality-protein { color: #f59e0b; }
58
- </style>
59
- """, unsafe_allow_html=True)
60
-
61
-
62
- @st.cache_resource
63
- def init_bioflow():
64
- """Initialize BioFlow components (cached)."""
65
- try:
66
- from bioflow.obm_wrapper import OBMWrapper
67
- from bioflow.qdrant_manager import QdrantManager
68
- from bioflow.pipeline import BioFlowPipeline, MinerAgent, ValidatorAgent
69
-
70
- obm = OBMWrapper()
71
- qdrant = QdrantManager(obm, qdrant_path=None) # In-memory
72
- qdrant.create_collection("bioflow_demo", recreate=True)
73
-
74
- pipeline = BioFlowPipeline(obm, qdrant)
75
- pipeline.register_agent(MinerAgent(obm, qdrant, "bioflow_demo"))
76
- pipeline.register_agent(ValidatorAgent(obm, qdrant, "bioflow_demo"))
77
-
78
- return {
79
- "obm": obm,
80
- "qdrant": qdrant,
81
- "pipeline": pipeline,
82
- "ready": True
83
- }
84
- except Exception as e:
85
- st.error(f"Failed to initialize: {e}")
86
- return {"ready": False, "error": str(e)}
87
-
88
-
89
- def render_sidebar():
90
- """Render the sidebar with controls."""
91
- st.sidebar.markdown("## 🧬 BioFlow Explorer")
92
- st.sidebar.markdown("---")
93
-
94
- # Mode selection
95
- mode = st.sidebar.selectbox(
96
- "Mode",
97
- ["🔍 Search & Explore", "📥 Data Ingestion", "🧪 Cross-Modal Analysis",
98
- "📊 Visualization", "🔬 Pipeline Demo", "📚 Documentation"]
99
- )
100
-
101
- st.sidebar.markdown("---")
102
-
103
- # Settings
104
- with st.sidebar.expander("⚙️ Settings"):
105
- vector_dim = st.number_input("Vector Dimension", value=768, disabled=True)
106
-
107
- st.sidebar.markdown("---")
108
- st.sidebar.markdown("### Quick Stats")
109
-
110
- return mode
111
-
112
-
113
- def render_search_page(components):
114
- """Render the search and explore page."""
115
- st.markdown('<p class="main-header">🔍 Search & Explore</p>', unsafe_allow_html=True)
116
-
117
- col1, col2 = st.columns([2, 1])
118
-
119
- with col1:
120
- query = st.text_area(
121
- "Enter your query",
122
- placeholder="e.g., 'KRAS inhibitor for cancer treatment' or a SMILES string like 'CCO'",
123
- height=100
124
- )
125
-
126
- query_modality = st.selectbox(
127
- "Query Modality",
128
- ["text", "smiles", "protein"],
129
- help="Select the type of your input"
130
- )
131
-
132
- with col2:
133
- target_modality = st.selectbox(
134
- "Search for",
135
- ["All", "text", "smiles", "protein"],
136
- help="Filter results by modality"
137
- )
138
-
139
- top_k = st.slider("Number of results", 1, 20, 5)
140
-
141
- if st.button("🔍 Search", type="primary"):
142
- if not query:
143
- st.warning("Please enter a query")
144
- return
145
-
146
- with st.spinner("Encoding and searching..."):
147
- obm = components["obm"]
148
- qdrant = components["qdrant"]
149
-
150
- # Encode query
151
- embedding = obm.encode(query, query_modality)
152
-
153
- # Display query embedding info
154
- with st.expander("📊 Query Embedding Details"):
155
- st.json({
156
- "modality": embedding.modality.value,
157
- "dimension": embedding.dimension,
158
- "content_hash": embedding.content_hash,
159
- "vector_sample": embedding.vector[:5].tolist()
160
- })
161
-
162
- # Search
163
- filter_mod = None if target_modality == "All" else target_modality
164
- results = qdrant.search(
165
- query=query,
166
- query_modality=query_modality,
167
- limit=top_k,
168
- filter_modality=filter_mod
169
- )
170
-
171
- if results:
172
- st.markdown("### 📋 Search Results")
173
- for i, r in enumerate(results):
174
- with st.container():
175
- col1, col2, col3 = st.columns([1, 4, 1])
176
- with col1:
177
- st.metric("Rank", i + 1)
178
- with col2:
179
- modality_class = f"modality-{r.modality}"
180
- st.markdown(f"**<span class='{modality_class}'>[{r.modality.upper()}]</span>** {r.content[:100]}...", unsafe_allow_html=True)
181
- with col3:
182
- st.metric("Score", f"{r.score:.3f}")
183
- st.divider()
184
- else:
185
- st.info("No results found. Try ingesting some data first!")
186
-
187
-
188
- def render_ingestion_page(components):
189
- """Render the data ingestion page."""
190
- st.markdown('<p class="main-header">📥 Data Ingestion</p>', unsafe_allow_html=True)
191
-
192
- tab1, tab2, tab3 = st.tabs(["📝 Single Entry", "📄 Batch Upload", "🧪 Sample Data"])
193
-
194
- with tab1:
195
- st.markdown("### Add Single Entry")
196
-
197
- col1, col2 = st.columns(2)
198
- with col1:
199
- content = st.text_area("Content", placeholder="Enter text, SMILES, or protein sequence")
200
- modality = st.selectbox("Type", ["text", "smiles", "protein"])
201
-
202
- with col2:
203
- source = st.text_input("Source", placeholder="e.g., PubMed:12345")
204
- tags = st.text_input("Tags (comma-separated)", placeholder="e.g., cancer, kinase")
205
-
206
- if st.button("➕ Add Entry"):
207
- if content:
208
- qdrant = components["qdrant"]
209
- item = {
210
- "content": content,
211
- "modality": modality,
212
- "source": source,
213
- "tags": [t.strip() for t in tags.split(",") if t.strip()]
214
- }
215
- stats = qdrant.ingest([item])
216
- st.success(f"Added successfully! Stats: {stats}")
217
- else:
218
- st.warning("Please enter content")
219
-
220
- with tab2:
221
- st.markdown("### Batch Upload")
222
-
223
- uploaded_file = st.file_uploader("Upload JSON or CSV", type=["json", "csv"])
224
-
225
- if uploaded_file:
226
- try:
227
- if uploaded_file.name.endswith('.json'):
228
- data = json.load(uploaded_file)
229
- else:
230
- df = pd.read_csv(uploaded_file)
231
- data = df.to_dict('records')
232
-
233
- st.write(f"Found {len(data)} entries")
234
- st.dataframe(pd.DataFrame(data).head())
235
-
236
- if st.button("📤 Upload All"):
237
- qdrant = components["qdrant"]
238
- stats = qdrant.ingest(data)
239
- st.success(f"Ingestion complete! {stats}")
240
- except Exception as e:
241
- st.error(f"Error parsing file: {e}")
242
-
243
- with tab3:
244
- st.markdown("### Load Sample Data")
245
- st.markdown("Load pre-defined sample data to test the system.")
246
-
247
- sample_data = [
248
- {"content": "Aspirin is used to reduce fever and relieve mild to moderate pain", "modality": "text", "source": "sample", "tags": ["pain", "fever"]},
249
- {"content": "CC(=O)OC1=CC=CC=C1C(=O)O", "modality": "smiles", "source": "ChEMBL", "tags": ["aspirin", "nsaid"]},
250
- {"content": "Ibuprofen is a nonsteroidal anti-inflammatory drug used for treating pain", "modality": "text", "source": "sample", "tags": ["pain", "nsaid"]},
251
- {"content": "CC(C)CC1=CC=C(C=C1)C(C)C(=O)O", "modality": "smiles", "source": "ChEMBL", "tags": ["ibuprofen", "nsaid"]},
252
- {"content": "KRAS mutations are found in many cancers and are difficult to target", "modality": "text", "source": "PubMed", "tags": ["cancer", "KRAS"]},
253
- {"content": "MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGETCLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHHYREQIKRVKDSEDVPMVLVGNKCDLPSRTVDTKQAQDLARSYGIPFIETSAKTRQGVDDAFYTLVREIRKHKEKMSKDGKKKKKKSKTKCVIM", "modality": "protein", "source": "UniProt:P01116", "tags": ["KRAS", "GTPase"]},
254
- {"content": "Sotorasib is a first-in-class KRAS G12C inhibitor", "modality": "text", "source": "PubMed", "tags": ["KRAS", "inhibitor", "cancer"]},
255
- {"content": "C[C@@H]1CC(=O)N(C2=C1C=CC(=C2)NC(=O)C3=CC=C(C=C3)N4CCN(CC4)C)C5=NC=CC(=N5)C6CCCCC6", "modality": "smiles", "source": "ChEMBL", "tags": ["sotorasib", "KRAS", "inhibitor"]},
256
- ]
257
-
258
- if st.button("🧪 Load Sample Data"):
259
- qdrant = components["qdrant"]
260
- stats = qdrant.ingest(sample_data)
261
- st.success(f"Loaded {len(sample_data)} sample entries! {stats}")
262
- st.balloons()
263
-
264
-
265
- def render_crossmodal_page(components):
266
- """Render cross-modal analysis page."""
267
- st.markdown('<p class="main-header">🧪 Cross-Modal Analysis</p>', unsafe_allow_html=True)
268
-
269
- st.markdown("""
270
- Explore how different modalities relate to each other in the shared embedding space.
271
- This is the core capability of BioFlow - connecting text, molecules, and proteins.
272
- """)
273
-
274
- col1, col2 = st.columns(2)
275
-
276
- with col1:
277
- st.markdown("### Query")
278
- query = st.text_area("Enter query", height=100)
279
- query_mod = st.selectbox("Query type", ["text", "smiles", "protein"], key="q_mod")
280
-
281
- with col2:
282
- st.markdown("### Targets")
283
- targets = st.text_area("Enter targets (one per line)", height=100)
284
- target_mod = st.selectbox("Target type", ["text", "smiles", "protein"], key="t_mod")
285
-
286
- if st.button("🔄 Compute Cross-Modal Similarity"):
287
- if query and targets:
288
- obm = components["obm"]
289
- target_list = [t.strip() for t in targets.strip().split("\n") if t.strip()]
290
-
291
- results = obm.cross_modal_similarity(
292
- query=query,
293
- query_modality=query_mod,
294
- targets=target_list,
295
- target_modality=target_mod
296
- )
297
-
298
- st.markdown("### Results (sorted by similarity)")
299
-
300
- df = pd.DataFrame(results, columns=["Content", "Similarity"])
301
- df["Rank"] = range(1, len(df) + 1)
302
- df = df[["Rank", "Content", "Similarity"]]
303
-
304
- st.dataframe(df, use_container_width=True)
305
-
306
- # Visualize
307
- import plotly.express as px
308
- fig = px.bar(df, x="Content", y="Similarity", title="Cross-Modal Similarities")
309
- st.plotly_chart(fig, use_container_width=True)
310
-
311
-
312
- def render_visualization_page(components):
313
- """Render visualization page."""
314
- st.markdown('<p class="main-header">📊 Visualization</p>', unsafe_allow_html=True)
315
-
316
- tab1, tab2, tab3 = st.tabs(["🌐 Embedding Space", "📈 Similarity Matrix", "🧬 Molecules"])
317
-
318
- with tab1:
319
- st.markdown("### Embedding Space Visualization")
320
-
321
- # Get all points from collection
322
- qdrant = components["qdrant"]
323
- info = qdrant.get_collection_info()
324
-
325
- if info.get("points_count", 0) == 0:
326
- st.warning("No data in collection. Go to Data Ingestion to add some!")
327
- return
328
-
329
- st.metric("Points in collection", info.get("points_count", 0))
330
-
331
- if st.button("🎨 Generate Embedding Plot"):
332
- # This would require fetching all vectors - simplified for demo
333
- st.info("Embedding visualization requires fetching all vectors. In production, use sampling.")
334
-
335
- # Demo with random data
336
- n_points = min(info.get("points_count", 20), 50)
337
- fake_embeddings = np.random.randn(n_points, 2)
338
-
339
- import plotly.express as px
340
- fig = px.scatter(
341
- x=fake_embeddings[:, 0],
342
- y=fake_embeddings[:, 1],
343
- title="Embedding Space (Demo - PCA projection)"
344
- )
345
- st.plotly_chart(fig, use_container_width=True)
346
-
347
- with tab2:
348
- st.markdown("### Compute Similarity Matrix")
349
-
350
- items = st.text_area("Enter items (one per line)", height=150)
351
- modality = st.selectbox("Modality", ["text", "smiles", "protein"], key="sim_mod")
352
-
353
- if st.button("🔢 Compute Matrix"):
354
- if items:
355
- obm = components["obm"]
356
- item_list = [i.strip() for i in items.strip().split("\n") if i.strip()]
357
-
358
- if modality == "text":
359
- embeddings = obm.encode_text(item_list)
360
- elif modality == "smiles":
361
- embeddings = obm.encode_smiles(item_list)
362
- else:
363
- embeddings = obm.encode_protein(item_list)
364
-
365
- vectors = np.array([e.vector for e in embeddings])
366
-
367
- # Compute similarity
368
- norms = np.linalg.norm(vectors, axis=1, keepdims=True)
369
- normalized = vectors / np.clip(norms, 1e-9, None)
370
- similarity = np.dot(normalized, normalized.T)
371
-
372
- import plotly.figure_factory as ff
373
- labels = [i[:20] for i in item_list]
374
- fig = ff.create_annotated_heatmap(
375
- similarity,
376
- x=labels,
377
- y=labels,
378
- colorscale='RdBu'
379
- )
380
- st.plotly_chart(fig, use_container_width=True)
381
-
382
- with tab3:
383
- st.markdown("### Molecule Visualization")
384
-
385
- smiles = st.text_input("Enter SMILES", placeholder="CC(=O)OC1=CC=CC=C1C(=O)O")
386
-
387
- if smiles:
388
- try:
389
- from rdkit import Chem
390
- from rdkit.Chem import Draw
391
-
392
- mol = Chem.MolFromSmiles(smiles)
393
- if mol:
394
- img = Draw.MolToImage(mol, size=(400, 300))
395
- st.image(img, caption=f"Molecule: {smiles}")
396
- else:
397
- st.error("Invalid SMILES")
398
- except ImportError:
399
- st.warning("RDKit not installed. Install with: pip install rdkit")
400
-
401
-
402
- def render_pipeline_page(components):
403
- """Render pipeline demo page."""
404
- st.markdown('<p class="main-header">🔬 Pipeline Demo</p>', unsafe_allow_html=True)
405
-
406
- st.markdown("""
407
- Run a complete discovery workflow that:
408
- 1. Searches for related literature
409
- 2. Finds similar molecules
410
- 3. Validates candidates
411
- 4. Analyzes result diversity
412
- """)
413
-
414
- query = st.text_input("Enter discovery query", placeholder="e.g., KRAS inhibitor for lung cancer")
415
-
416
- col1, col2 = st.columns(2)
417
- with col1:
418
- query_mod = st.selectbox("Query modality", ["text", "smiles", "protein"])
419
- with col2:
420
- target_mod = st.selectbox("Target modality", ["smiles", "text", "protein"])
421
-
422
- if st.button("🚀 Run Discovery Pipeline", type="primary"):
423
- if query:
424
- pipeline = components["pipeline"]
425
-
426
- with st.spinner("Running pipeline..."):
427
- results = pipeline.run_discovery_workflow(
428
- query=query,
429
- query_modality=query_mod,
430
- target_modality=target_mod
431
- )
432
-
433
- st.markdown("## 📊 Pipeline Results")
434
-
435
- # Literature
436
- with st.expander("📚 Related Literature", expanded=True):
437
- lit = results.get("stages", {}).get("literature", [])
438
- if lit:
439
- for item in lit:
440
- st.markdown(f"- **Score: {item['score']:.3f}** - {item['content'][:100]}...")
441
- else:
442
- st.info("No literature found")
443
-
444
- # Molecules
445
- with st.expander("🧪 Similar Molecules", expanded=True):
446
- mols = results.get("stages", {}).get("molecules", [])
447
- if mols:
448
- df = pd.DataFrame(mols)
449
- st.dataframe(df)
450
- else:
451
- st.info("No molecules found")
452
-
453
- # Validation
454
- with st.expander("✅ Validation Results"):
455
- val = results.get("stages", {}).get("validation", [])
456
- if val:
457
- st.json(val)
458
- else:
459
- st.info("No validation performed")
460
-
461
- # Diversity
462
- with st.expander("📈 Diversity Analysis"):
463
- div = results.get("stages", {}).get("diversity", {})
464
- if div:
465
- col1, col2, col3 = st.columns(3)
466
- col1.metric("Mean Similarity", f"{div.get('mean_similarity', 0):.3f}")
467
- col2.metric("Diversity Score", f"{div.get('diversity_score', 0):.3f}")
468
- col3.metric("Modalities", len(div.get('modality_distribution', {})))
469
- st.json(div)
470
-
471
-
472
- def render_docs_page():
473
- """Render documentation page."""
474
- st.markdown('<p class="main-header">📚 Documentation</p>', unsafe_allow_html=True)
475
-
476
- st.markdown("""
477
- ## BioFlow + OpenBioMed Integration
478
-
479
- ### 🎯 Overview
480
-
481
- BioFlow is a multimodal biological intelligence framework that leverages OpenBioMed (OBM)
482
- for encoding biological data and Qdrant for vector storage and retrieval.
483
-
484
- ### 🧩 Components
485
-
486
- | Component | Description |
487
- |-----------|-------------|
488
- | **OBMWrapper** | Encodes text, molecules (SMILES), and proteins into a shared vector space |
489
- | **QdrantManager** | Manages vector storage, indexing, and similarity search |
490
- | **BioFlowPipeline** | Orchestrates agents in discovery workflows |
491
- | **Visualizer** | Creates plots for embeddings, similarities, and molecules |
492
-
493
- ### 🔌 API Examples
494
-
495
- ```python
496
- from bioflow import OBMWrapper, QdrantManager, BioFlowPipeline
497
-
498
- # Initialize
499
- obm = OBMWrapper(device="cuda")
500
- qdrant = QdrantManager(obm, qdrant_path="./data/qdrant")
501
-
502
- # Encode different modalities
503
- text_vec = obm.encode_text("KRAS inhibitor for cancer")
504
- mol_vec = obm.encode_smiles("CCO")
505
- prot_vec = obm.encode_protein("MTEYKLVVV...")
506
-
507
- # Cross-modal search
508
- results = qdrant.cross_modal_search(
509
- query="anti-inflammatory drug",
510
- query_modality="text",
511
- target_modality="smiles",
512
- limit=10
513
- )
514
- ```
515
-
516
- ### 🌟 Key Features
517
-
518
- 1. **Unified Embedding Space**: All modalities map to the same vector dimension
519
- 2. **Cross-Modal Search**: Find molecules from text queries and vice versa
520
- 3. **Pipeline Orchestration**: Chain agents for complex discovery workflows
521
- 4. **Mock Mode**: Test without GPU using deterministic random embeddings
522
-
523
- ### 📁 File Structure
524
-
525
- ```
526
- bioflow/
527
- ├── __init__.py # Package exports
528
- ├── obm_wrapper.py # OBM encoding interface
529
- ├── qdrant_manager.py # Qdrant operations
530
- ├── pipeline.py # Workflow orchestration
531
- ├── visualizer.py # Visualization utilities
532
- └── app.py # Streamlit interface
533
- ```
534
- """)
535
-
536
-
537
- def main():
538
- """Main application entry point."""
539
- mode = render_sidebar()
540
-
541
- # Initialize components
542
- components = init_bioflow()
543
-
544
- if not components.get("ready"):
545
- st.error("System not ready. Check configuration.")
546
- return
547
-
548
- # Display collection stats in sidebar
549
- info = components["qdrant"].get_collection_info()
550
- st.sidebar.metric("📊 Vectors", info.get("points_count", 0))
551
- st.sidebar.metric("📐 Dimension", info.get("vector_size", 768))
552
-
553
- # Route to appropriate page
554
- if "Search" in mode:
555
- render_search_page(components)
556
- elif "Ingestion" in mode:
557
- render_ingestion_page(components)
558
- elif "Cross-Modal" in mode:
559
- render_crossmodal_page(components)
560
- elif "Visualization" in mode:
561
- render_visualization_page(components)
562
- elif "Pipeline" in mode:
563
- render_pipeline_page(components)
564
- elif "Documentation" in mode:
565
- render_docs_page()
566
-
567
-
568
- if __name__ == "__main__":
569
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/demo.py CHANGED
@@ -221,7 +221,9 @@ def demo_visualization():
221
  print(" - plot_embeddings_3d(embeddings, labels)")
222
  print(" - plot_similarity_matrix(embeddings, labels)")
223
  print(" - create_dashboard(results, embeddings)")
224
- print("\n Run the Streamlit app to see interactive visualizations!")
 
 
225
 
226
  except ImportError as e:
227
  print(f"⚠️ Some visualization dependencies missing: {e}")
@@ -245,8 +247,9 @@ def main():
245
 
246
  print_header("✅ Demo Complete!")
247
  print("Next steps:")
248
- print(" 1. Run the Streamlit interface:")
249
- print(" streamlit run bioflow/app.py")
 
250
  print("")
251
  print(" 2. Ensure OBM model is configured:")
252
  print(" - BioMedGPT checkpoints are downloaded")
 
221
  print(" - plot_embeddings_3d(embeddings, labels)")
222
  print(" - plot_similarity_matrix(embeddings, labels)")
223
  print(" - create_dashboard(results, embeddings)")
224
+ print("\n Use the Next.js UI for interactive visualizations:")
225
+ print(" - Start backend: python -m uvicorn bioflow.api.server:app --host 0.0.0.0 --port 8000")
226
+ print(" - Start UI: cd ui && pnpm dev")
227
 
228
  except ImportError as e:
229
  print(f"⚠️ Some visualization dependencies missing: {e}")
 
247
 
248
  print_header("✅ Demo Complete!")
249
  print("Next steps:")
250
+ print(" 1. Run the full stack:")
251
+ print(" - Backend: python -m uvicorn bioflow.api.server:app --host 0.0.0.0 --port 8000")
252
+ print(" - UI: cd ui && pnpm dev")
253
  print("")
254
  print(" 2. Ensure OBM model is configured:")
255
  print(" - BioMedGPT checkpoints are downloaded")
bioflow/evaluation/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BioFlow Evaluation
3
+ ==================
4
+
5
+ Offline evaluation utilities for retrieval and diversification quality.
6
+ """
7
+
8
+ from .metrics import (
9
+ recall_at_k,
10
+ mrr_at_k,
11
+ ndcg_at_k,
12
+ intra_list_diversity_cosine,
13
+ )
14
+
15
+ __all__ = [
16
+ "recall_at_k",
17
+ "mrr_at_k",
18
+ "ndcg_at_k",
19
+ "intra_list_diversity_cosine",
20
+ ]
21
+
bioflow/evaluation/metrics.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Dict, Iterable, List, Mapping, Sequence, Set
5
+
6
+
7
+ def recall_at_k(relevant: Set[str], ranked: Sequence[str], k: int) -> float:
8
+ if k <= 0:
9
+ return 0.0
10
+ if not relevant:
11
+ return 0.0
12
+ top = set(ranked[:k])
13
+ return len(relevant.intersection(top)) / float(len(relevant))
14
+
15
+
16
+ def mrr_at_k(relevant: Set[str], ranked: Sequence[str], k: int) -> float:
17
+ if k <= 0:
18
+ return 0.0
19
+ if not relevant:
20
+ return 0.0
21
+ for i, doc_id in enumerate(ranked[:k]):
22
+ if doc_id in relevant:
23
+ return 1.0 / float(i + 1)
24
+ return 0.0
25
+
26
+
27
+ def _dcg(relevances: Sequence[float], k: int) -> float:
28
+ total = 0.0
29
+ for i, rel in enumerate(relevances[:k]):
30
+ total += (2.0 ** float(rel) - 1.0) / math.log2(float(i + 2))
31
+ return total
32
+
33
+
34
+ def ndcg_at_k(relevance_by_id: Mapping[str, float], ranked: Sequence[str], k: int) -> float:
35
+ if k <= 0:
36
+ return 0.0
37
+ if not relevance_by_id:
38
+ return 0.0
39
+
40
+ rels = [float(relevance_by_id.get(doc_id, 0.0)) for doc_id in ranked]
41
+ dcg = _dcg(rels, k)
42
+
43
+ ideal_rels = sorted(relevance_by_id.values(), reverse=True)
44
+ idcg = _dcg(ideal_rels, k)
45
+ if idcg == 0.0:
46
+ return 0.0
47
+ return dcg / idcg
48
+
49
+
50
+ def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
51
+ dot = 0.0
52
+ na = 0.0
53
+ nb = 0.0
54
+ for x, y in zip(a, b):
55
+ dot += float(x) * float(y)
56
+ na += float(x) * float(x)
57
+ nb += float(y) * float(y)
58
+ denom = math.sqrt(na) * math.sqrt(nb)
59
+ return (dot / denom) if denom else 0.0
60
+
61
+
62
+ def cosine_distance(a: Sequence[float], b: Sequence[float]) -> float:
63
+ return 1.0 - cosine_similarity(a, b)
64
+
65
+
66
+ def intra_list_diversity_cosine(embeddings: Sequence[Sequence[float]]) -> float:
67
+ """
68
+ Average pairwise cosine distance within a list.
69
+ Higher = more diverse.
70
+ """
71
+ n = len(embeddings)
72
+ if n < 2:
73
+ return 1.0
74
+
75
+ total = 0.0
76
+ count = 0
77
+ for i in range(n):
78
+ for j in range(i + 1, n):
79
+ total += cosine_distance(embeddings[i], embeddings[j])
80
+ count += 1
81
+ return total / float(count) if count else 1.0
82
+
bioflow/ingestion/pubmed_ingestor.py CHANGED
@@ -15,6 +15,7 @@ Usage:
15
  import logging
16
  import requests
17
  import xml.etree.ElementTree as ET
 
18
  from typing import Dict, Any, Optional, Generator
19
  from datetime import datetime
20
 
@@ -134,6 +135,7 @@ class PubMedIngestor(BaseIngestor):
134
  # Extract publication date
135
  pub_date = ""
136
  date_elem = article.find(".//PubDate")
 
137
  if date_elem is not None:
138
  year = date_elem.find("Year")
139
  month = date_elem.find("Month")
@@ -141,6 +143,14 @@ class PubMedIngestor(BaseIngestor):
141
  pub_date = year.text
142
  if month is not None:
143
  pub_date = f"{year.text}-{month.text}"
 
 
 
 
 
 
 
 
144
 
145
  # Extract authors
146
  authors = []
@@ -170,6 +180,7 @@ class PubMedIngestor(BaseIngestor):
170
  "authors": authors,
171
  "journal": journal,
172
  "pub_date": pub_date,
 
173
  "mesh_terms": mesh_terms,
174
  }
175
 
@@ -246,6 +257,7 @@ class PubMedIngestor(BaseIngestor):
246
  "authors": raw_data.get("authors", []),
247
  "journal": raw_data.get("journal", ""),
248
  "pub_date": raw_data.get("pub_date", ""),
 
249
  "mesh_terms": raw_data.get("mesh_terms", []),
250
  "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
251
  }
 
15
  import logging
16
  import requests
17
  import xml.etree.ElementTree as ET
18
+ import re
19
  from typing import Dict, Any, Optional, Generator
20
  from datetime import datetime
21
 
 
135
  # Extract publication date
136
  pub_date = ""
137
  date_elem = article.find(".//PubDate")
138
+ year_value = None
139
  if date_elem is not None:
140
  year = date_elem.find("Year")
141
  month = date_elem.find("Month")
 
143
  pub_date = year.text
144
  if month is not None:
145
  pub_date = f"{year.text}-{month.text}"
146
+ try:
147
+ year_value = int(year.text)
148
+ except (TypeError, ValueError):
149
+ year_value = None
150
+ if year_value is None and pub_date:
151
+ match = re.match(r"(\\d{4})", pub_date)
152
+ if match:
153
+ year_value = int(match.group(1))
154
 
155
  # Extract authors
156
  authors = []
 
180
  "authors": authors,
181
  "journal": journal,
182
  "pub_date": pub_date,
183
+ "year": year_value,
184
  "mesh_terms": mesh_terms,
185
  }
186
 
 
257
  "authors": raw_data.get("authors", []),
258
  "journal": raw_data.get("journal", ""),
259
  "pub_date": raw_data.get("pub_date", ""),
260
+ "year": raw_data.get("year"),
261
  "mesh_terms": raw_data.get("mesh_terms", []),
262
  "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
263
  }
bioflow/pipeline.py CHANGED
@@ -4,6 +4,9 @@ BioFlow Pipeline - Workflow Orchestration
4
 
5
  This module provides the pipeline orchestration for BioFlow,
6
  connecting agents, memory (Qdrant), and OBM encoders.
 
 
 
7
  """
8
 
9
  import logging
 
4
 
5
  This module provides the pipeline orchestration for BioFlow,
6
  connecting agents, memory (Qdrant), and OBM encoders.
7
+
8
+ DEPRECATED (legacy): Prefer the FastAPI backend (`bioflow/api/server.py`) and
9
+ the agent system (`bioflow/agents/*`). This module is kept for older demos/scripts.
10
  """
11
 
12
  import logging
bioflow/qdrant_manager.py CHANGED
@@ -4,6 +4,9 @@ Qdrant Manager - Vector Database Integration
4
 
5
  This module provides high-level management for Qdrant collections,
6
  including ingestion, search, and retrieval operations for BioFlow.
 
 
 
7
  """
8
 
9
  import logging
 
4
 
5
  This module provides high-level management for Qdrant collections,
6
  including ingestion, search, and retrieval operations for BioFlow.
7
+
8
+ DEPRECATED (legacy): Prefer `bioflow/api/qdrant_service.py` for the active FastAPI backend.
9
+ This module is kept for backward compatibility with older demos/scripts.
10
  """
11
 
12
  import logging
bioflow/search/enhanced_search.py CHANGED
@@ -25,8 +25,12 @@ import logging
25
  from typing import List, Dict, Any, Optional, Union
26
  from dataclasses import dataclass, field
27
  from datetime import datetime
 
 
 
28
 
29
  from bioflow.search.mmr import MMRReranker, MMRResult
 
30
  from bioflow.search.evidence import EvidenceLinker, EnrichedResult, EvidenceLink
31
 
32
  logger = logging.getLogger(__name__)
@@ -80,6 +84,8 @@ class EnhancedSearchResult:
80
  }
81
  for l in self.evidence_links
82
  ],
 
 
83
  "source_type": self.source_type,
84
  "citation": self.citation,
85
  "rank": self.rank,
@@ -123,6 +129,8 @@ class EnhancedSearchService:
123
  obm_encoder,
124
  default_lambda: float = 0.7,
125
  default_top_k: int = 20,
 
 
126
  ):
127
  """
128
  Initialize enhanced search service.
@@ -138,6 +146,12 @@ class EnhancedSearchService:
138
  self.mmr_reranker = MMRReranker(lambda_param=default_lambda)
139
  self.evidence_linker = EvidenceLinker()
140
  self.default_top_k = default_top_k
 
 
 
 
 
 
141
 
142
  def search(
143
  self,
@@ -177,33 +191,54 @@ class EnhancedSearchService:
177
  elif filters is None:
178
  filters = SearchFilters()
179
 
180
- # Get query embedding
 
 
 
 
 
181
  from bioflow.core.base import Modality
182
- modality_enum = self._get_modality_enum(modality)
183
- query_result = self.encoder.encode(query, modality_enum)
184
- query_embedding = query_result.vector
185
 
186
  # Execute vector search
187
  # Fetch more results if using MMR (for better diversity)
188
  fetch_limit = top_k * 3 if use_mmr else top_k
 
189
 
190
  raw_results = self._execute_search(
191
  query_embedding=query_embedding,
192
  collection=collection,
193
  limit=fetch_limit,
194
  filters=filters,
 
195
  )
 
 
 
196
 
197
  total_found = len(raw_results)
198
 
199
  # Apply MMR if requested
200
  if use_mmr and len(raw_results) > 1:
201
  # Get embeddings for MMR
202
- embeddings = self._get_result_embeddings(raw_results) if include_embeddings or use_mmr else None
 
 
 
 
 
 
 
 
 
 
203
 
204
- mmr_results = self.mmr_reranker.rerank(
205
  results=raw_results,
206
  query_embedding=query_embedding,
 
207
  embeddings=embeddings,
208
  top_k=top_k,
209
  )
@@ -226,7 +261,7 @@ class EnhancedSearchService:
226
  return SearchResponse(
227
  results=enhanced_results,
228
  query=query,
229
- modality=modality,
230
  total_found=total_found,
231
  returned=len(enhanced_results),
232
  diversity_score=diversity_score,
@@ -314,38 +349,56 @@ class EnhancedSearchService:
314
  collection: str,
315
  limit: int,
316
  filters: SearchFilters,
 
317
  ) -> List[Dict[str, Any]]:
318
  """Execute search against Qdrant."""
319
- from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
320
 
321
  # Build filter conditions
322
- conditions = []
323
 
324
  if filters.modality:
325
- conditions.append(FieldCondition(
326
- key="modality",
327
- match=MatchValue(value=filters.modality)
328
- ))
 
 
 
 
 
 
 
 
329
 
330
  if filters.source:
331
- conditions.append(FieldCondition(
332
  key="source",
333
  match=MatchValue(value=filters.source)
334
  ))
335
 
336
  if filters.sources:
337
- # Multiple sources - need OR logic
338
- # Qdrant supports this via should conditions
339
- pass # TODO: Implement OR logic
 
340
 
341
  if filters.organism:
342
- conditions.append(FieldCondition(
343
  key="organism",
344
  match=MatchValue(value=filters.organism)
345
  ))
 
 
 
 
 
 
346
 
347
  # Build filter
348
- query_filter = Filter(must=conditions) if conditions else None
 
 
349
 
350
  # Get client and search
351
  client = self.qdrant._get_client()
@@ -366,17 +419,20 @@ class EnhancedSearchService:
366
  limit=limit,
367
  query_filter=query_filter,
368
  with_payload=True,
369
- with_vectors=True, # Need vectors for MMR
370
  ).points
371
 
372
  for r in results:
 
 
 
373
  all_results.append({
374
  'id': str(r.id),
375
  'score': r.score,
376
  'content': r.payload.get('content', ''),
377
- 'modality': r.payload.get('modality', 'unknown'),
378
  'metadata': r.payload,
379
- 'vector': r.vector,
380
  })
381
  except Exception as e:
382
  logger.warning(f"Search in {coll} failed: {e}")
@@ -385,7 +441,11 @@ class EnhancedSearchService:
385
  all_results.sort(key=lambda x: x['score'], reverse=True)
386
  return all_results[:limit]
387
 
388
- def _get_result_embeddings(self, results: List[Dict]) -> List[List[float]]:
 
 
 
 
389
  """Extract embeddings from results."""
390
  embeddings = []
391
  for r in results:
@@ -397,9 +457,54 @@ class EnhancedSearchService:
397
  embeddings.append(vec)
398
  else:
399
  # Missing vector - use zeros (will have low similarity)
400
- embeddings.append([0.0] * 768)
401
  return embeddings
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  def _mmr_to_enhanced(
404
  self,
405
  mmr_results: List[MMRResult],
@@ -417,7 +522,7 @@ class EnhancedSearchService:
417
  modality=r.modality,
418
  metadata=r.metadata,
419
  evidence_links=[], # Added later
420
- source_type=r.metadata.get('source', 'unknown'),
421
  citation=None, # Added later
422
  rank=i + 1,
423
  ))
@@ -427,6 +532,7 @@ class EnhancedSearchService:
427
  """Convert raw results to enhanced results."""
428
  enhanced = []
429
  for i, r in enumerate(results):
 
430
  enhanced.append(EnhancedSearchResult(
431
  id=r.get('id', ''),
432
  score=r.get('score', 0),
@@ -434,9 +540,9 @@ class EnhancedSearchService:
434
  diversity_penalty=None,
435
  content=r.get('content', ''),
436
  modality=r.get('modality', 'unknown'),
437
- metadata=r.get('metadata', {}),
438
  evidence_links=[],
439
- source_type=r.get('metadata', {}).get('source', 'unknown'),
440
  citation=None,
441
  rank=i + 1,
442
  ))
@@ -469,6 +575,107 @@ class EnhancedSearchService:
469
  "protein": Modality.PROTEIN,
470
  }
471
  return mapping.get(modality.lower(), Modality.TEXT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
 
473
  def _filters_to_dict(self, filters: SearchFilters) -> Dict[str, Any]:
474
  """Convert filters to dictionary."""
 
25
  from typing import List, Dict, Any, Optional, Union
26
  from dataclasses import dataclass, field
27
  from datetime import datetime
28
+ import re
29
+ import threading
30
+ from collections import OrderedDict
31
 
32
  from bioflow.search.mmr import MMRReranker, MMRResult
33
+ from bioflow.search.mmr import mmr_rerank
34
  from bioflow.search.evidence import EvidenceLinker, EnrichedResult, EvidenceLink
35
 
36
  logger = logging.getLogger(__name__)
 
84
  }
85
  for l in self.evidence_links
86
  ],
87
+ # UI expects `source`; keep `source_type` for backward compatibility.
88
+ "source": self.source_type,
89
  "source_type": self.source_type,
90
  "citation": self.citation,
91
  "rank": self.rank,
 
129
  obm_encoder,
130
  default_lambda: float = 0.7,
131
  default_top_k: int = 20,
132
+ query_cache_max_size: int = 256,
133
+ query_cache_ttl_s: float = 300.0,
134
  ):
135
  """
136
  Initialize enhanced search service.
 
146
  self.mmr_reranker = MMRReranker(lambda_param=default_lambda)
147
  self.evidence_linker = EvidenceLinker()
148
  self.default_top_k = default_top_k
149
+
150
+ # Simple in-memory cache for query embeddings (reduces repeated encoding latency).
151
+ self._query_cache_max_size = int(query_cache_max_size)
152
+ self._query_cache_ttl_s = float(query_cache_ttl_s)
153
+ self._query_cache: "OrderedDict[tuple[str, str], tuple[float, List[float]]]" = OrderedDict()
154
+ self._query_cache_lock = threading.Lock()
155
 
156
  def search(
157
  self,
 
191
  elif filters is None:
192
  filters = SearchFilters()
193
 
194
+ # Normalize / auto-detect modality for encoding.
195
+ requested_modality = (modality or "text").lower()
196
+ if requested_modality == "auto":
197
+ requested_modality = self._detect_modality(query)
198
+
199
+ # Get query embedding (cached)
200
  from bioflow.core.base import Modality
201
+ modality_enum = self._get_modality_enum(requested_modality)
202
+ query_embedding = self._get_query_embedding_cached(query, modality_enum)
203
+ query_dim = len(query_embedding)
204
 
205
  # Execute vector search
206
  # Fetch more results if using MMR (for better diversity)
207
  fetch_limit = top_k * 3 if use_mmr else top_k
208
+ need_vectors = bool(use_mmr or include_embeddings)
209
 
210
  raw_results = self._execute_search(
211
  query_embedding=query_embedding,
212
  collection=collection,
213
  limit=fetch_limit,
214
  filters=filters,
215
+ with_vectors=need_vectors,
216
  )
217
+
218
+ # Post-filters that are difficult to express robustly in Qdrant filters.
219
+ raw_results = self._apply_post_filters(raw_results, filters)
220
 
221
  total_found = len(raw_results)
222
 
223
  # Apply MMR if requested
224
  if use_mmr and len(raw_results) > 1:
225
  # Get embeddings for MMR
226
+ embeddings = (
227
+ self._get_result_embeddings(raw_results, expected_dim=query_dim)
228
+ if include_embeddings or use_mmr
229
+ else None
230
+ )
231
+
232
+ effective_lambda = (
233
+ float(lambda_param)
234
+ if lambda_param is not None
235
+ else float(self.mmr_reranker.lambda_param)
236
+ )
237
 
238
+ mmr_results = mmr_rerank(
239
  results=raw_results,
240
  query_embedding=query_embedding,
241
+ lambda_param=effective_lambda,
242
  embeddings=embeddings,
243
  top_k=top_k,
244
  )
 
261
  return SearchResponse(
262
  results=enhanced_results,
263
  query=query,
264
+ modality=requested_modality,
265
  total_found=total_found,
266
  returned=len(enhanced_results),
267
  diversity_score=diversity_score,
 
349
  collection: str,
350
  limit: int,
351
  filters: SearchFilters,
352
+ with_vectors: bool,
353
  ) -> List[Dict[str, Any]]:
354
  """Execute search against Qdrant."""
355
+ from qdrant_client.models import Filter, FieldCondition, MatchValue
356
 
357
  # Build filter conditions
358
+ must_conditions = []
359
 
360
  if filters.modality:
361
+ requested = str(filters.modality).lower()
362
+ if requested in ("molecule", "smiles"):
363
+ # Historical payloads may use "smiles". Treat both as molecule.
364
+ must_conditions.append(Filter(should=[
365
+ FieldCondition(key="modality", match=MatchValue(value="molecule")),
366
+ FieldCondition(key="modality", match=MatchValue(value="smiles")),
367
+ ]))
368
+ else:
369
+ must_conditions.append(FieldCondition(
370
+ key="modality",
371
+ match=MatchValue(value=requested)
372
+ ))
373
 
374
  if filters.source:
375
+ must_conditions.append(FieldCondition(
376
  key="source",
377
  match=MatchValue(value=filters.source)
378
  ))
379
 
380
  if filters.sources:
381
+ must_conditions.append(Filter(should=[
382
+ FieldCondition(key="source", match=MatchValue(value=s))
383
+ for s in filters.sources
384
+ ]))
385
 
386
  if filters.organism:
387
+ must_conditions.append(FieldCondition(
388
  key="organism",
389
  match=MatchValue(value=filters.organism)
390
  ))
391
+
392
+ if filters.organism_id is not None:
393
+ must_conditions.append(FieldCondition(
394
+ key="organism_id",
395
+ match=MatchValue(value=filters.organism_id)
396
+ ))
397
 
398
  # Build filter
399
+ query_filter = None
400
+ if must_conditions:
401
+ query_filter = Filter(must=must_conditions)
402
 
403
  # Get client and search
404
  client = self.qdrant._get_client()
 
419
  limit=limit,
420
  query_filter=query_filter,
421
  with_payload=True,
422
+ with_vectors=with_vectors,
423
  ).points
424
 
425
  for r in results:
426
+ payload_modality = r.payload.get('modality', 'unknown')
427
+ # Normalize legacy modality value for UI consistency.
428
+ normalized_modality = "molecule" if payload_modality == "smiles" else payload_modality
429
  all_results.append({
430
  'id': str(r.id),
431
  'score': r.score,
432
  'content': r.payload.get('content', ''),
433
+ 'modality': normalized_modality,
434
  'metadata': r.payload,
435
+ 'vector': r.vector if with_vectors else None,
436
  })
437
  except Exception as e:
438
  logger.warning(f"Search in {coll} failed: {e}")
 
441
  all_results.sort(key=lambda x: x['score'], reverse=True)
442
  return all_results[:limit]
443
 
444
+ def _get_result_embeddings(
445
+ self,
446
+ results: List[Dict],
447
+ expected_dim: int,
448
+ ) -> List[List[float]]:
449
  """Extract embeddings from results."""
450
  embeddings = []
451
  for r in results:
 
457
  embeddings.append(vec)
458
  else:
459
  # Missing vector - use zeros (will have low similarity)
460
+ embeddings.append([0.0] * expected_dim)
461
  return embeddings
462
 
463
+ def _extract_source(self, metadata: Dict[str, Any]) -> str:
464
+ """
465
+ Extract source from metadata with fallback chain.
466
+
467
+ Tries multiple field names and patterns to ensure traceability.
468
+ """
469
+ if not metadata:
470
+ return "unknown"
471
+
472
+ # Direct source fields (priority order)
473
+ for field in ["source", "database", "origin", "db", "data_source"]:
474
+ val = metadata.get(field)
475
+ if val and isinstance(val, str):
476
+ return val.lower()
477
+
478
+ # Check for known identifiers that imply source
479
+ if metadata.get("pmid") or metadata.get("pubmed_id"):
480
+ return "pubmed"
481
+ if metadata.get("uniprot_id") or metadata.get("accession"):
482
+ return "uniprot"
483
+ if metadata.get("chembl_id"):
484
+ return "chembl"
485
+ if metadata.get("drugbank_id"):
486
+ return "drugbank"
487
+ if metadata.get("pdb_id"):
488
+ return "pdb"
489
+
490
+ # Check for URL patterns
491
+ url = metadata.get("url", "")
492
+ if "pubmed" in url.lower() or "ncbi.nlm.nih.gov" in url.lower():
493
+ return "pubmed"
494
+ if "uniprot" in url.lower():
495
+ return "uniprot"
496
+ if "chembl" in url.lower():
497
+ return "chembl"
498
+
499
+ # Check modality hints
500
+ modality = metadata.get("modality", "")
501
+ if modality in ("protein", "sequence"):
502
+ return "protein_db"
503
+ if modality in ("molecule", "smiles", "compound"):
504
+ return "molecule_db"
505
+
506
+ return "unknown"
507
+
508
  def _mmr_to_enhanced(
509
  self,
510
  mmr_results: List[MMRResult],
 
522
  modality=r.modality,
523
  metadata=r.metadata,
524
  evidence_links=[], # Added later
525
+ source_type=self._extract_source(r.metadata),
526
  citation=None, # Added later
527
  rank=i + 1,
528
  ))
 
532
  """Convert raw results to enhanced results."""
533
  enhanced = []
534
  for i, r in enumerate(results):
535
+ metadata = r.get('metadata', {})
536
  enhanced.append(EnhancedSearchResult(
537
  id=r.get('id', ''),
538
  score=r.get('score', 0),
 
540
  diversity_penalty=None,
541
  content=r.get('content', ''),
542
  modality=r.get('modality', 'unknown'),
543
+ metadata=metadata,
544
  evidence_links=[],
545
+ source_type=self._extract_source(metadata),
546
  citation=None,
547
  rank=i + 1,
548
  ))
 
575
  "protein": Modality.PROTEIN,
576
  }
577
  return mapping.get(modality.lower(), Modality.TEXT)
578
+
579
+ def _get_query_embedding_cached(self, query: str, modality_enum) -> List[float]:
580
+ import time
581
+
582
+ key = (getattr(modality_enum, "value", str(modality_enum)), str(query))
583
+ now = time.time()
584
+
585
+ with self._query_cache_lock:
586
+ cached = self._query_cache.get(key)
587
+ if cached is not None:
588
+ ts, vec = cached
589
+ if (now - ts) <= self._query_cache_ttl_s:
590
+ self._query_cache.move_to_end(key)
591
+ return vec
592
+ self._query_cache.pop(key, None)
593
+
594
+ query_result = self.encoder.encode(query, modality_enum)
595
+ vec = query_result.vector.tolist() if hasattr(query_result.vector, "tolist") else list(query_result.vector)
596
+
597
+ with self._query_cache_lock:
598
+ self._query_cache[key] = (now, vec)
599
+ self._query_cache.move_to_end(key)
600
+ while len(self._query_cache) > self._query_cache_max_size:
601
+ self._query_cache.popitem(last=False)
602
+
603
+ return vec
604
+
605
+ def _detect_modality(self, query: str) -> str:
606
+ """
607
+ Heuristically detect modality from raw query.
608
+
609
+ Notes:
610
+ - "protein": long sequences of amino-acid letters
611
+ - "molecule": SMILES-like strings (bond symbols, brackets, digits, etc.)
612
+ - otherwise: "text"
613
+ """
614
+ q = (query or "").strip()
615
+ if not q:
616
+ return "text"
617
+
618
+ # Protein FASTA / sequence heuristic (AAs + length)
619
+ seq = q.replace("\n", "").replace(" ", "")
620
+ if len(seq) >= 25 and re.fullmatch(r"[ACDEFGHIKLMNPQRSTVWYBXZJUO]+", seq, flags=re.IGNORECASE):
621
+ return "protein"
622
+
623
+ # SMILES heuristic: contains typical SMILES tokens and at least one letter/digit.
624
+ if re.search(r"[\[\]=#@\\/()%0-9]", q) and re.search(r"[A-Za-z]", q):
625
+ return "molecule"
626
+
627
+ return "text"
628
+
629
+ def _apply_post_filters(
630
+ self,
631
+ results: List[Dict[str, Any]],
632
+ filters: SearchFilters,
633
+ ) -> List[Dict[str, Any]]:
634
+ """Apply filters that are not reliably expressed in Qdrant payload filters."""
635
+ if not results:
636
+ return results
637
+
638
+ year_min = filters.year_min
639
+ year_max = filters.year_max
640
+ keywords = filters.keywords or []
641
+
642
+ def extract_year(payload: Dict[str, Any]) -> Optional[int]:
643
+ for key in ("year", "publication_year", "pub_year", "date"):
644
+ v = payload.get(key)
645
+ if v is None:
646
+ continue
647
+ if isinstance(v, int):
648
+ return v
649
+ if isinstance(v, str):
650
+ m = re.search(r"(19\d{2}|20\d{2})", v)
651
+ if m:
652
+ try:
653
+ return int(m.group(1))
654
+ except Exception:
655
+ return None
656
+ return None
657
+
658
+ filtered: List[Dict[str, Any]] = []
659
+ for r in results:
660
+ payload = r.get("metadata", {}) or {}
661
+
662
+ if year_min is not None or year_max is not None:
663
+ y = extract_year(payload)
664
+ if y is None:
665
+ continue
666
+ if year_min is not None and y < year_min:
667
+ continue
668
+ if year_max is not None and y > year_max:
669
+ continue
670
+
671
+ if keywords:
672
+ hay = (r.get("content", "") or "").lower() + " " + str(payload).lower()
673
+ if not all(str(kw).lower() in hay for kw in keywords):
674
+ continue
675
+
676
+ filtered.append(r)
677
+
678
+ return filtered
679
 
680
  def _filters_to_dict(self, filters: SearchFilters) -> Dict[str, Any]:
681
  """Convert filters to dictionary."""
bioflow/ui/__init__.py DELETED
@@ -1,15 +0,0 @@
1
- """
2
- BioFlow UI Package
3
- ===================
4
-
5
- Modern Streamlit-based interface for the BioFlow platform.
6
-
7
- Pages:
8
- - Home: Dashboard with key metrics and quick actions
9
- - Discovery: Drug discovery pipeline interface
10
- - Explorer: Vector space visualization
11
- - Data: Data ingestion and management
12
- - Settings: Configuration and preferences
13
- """
14
-
15
- __version__ = "2.0.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/app.py DELETED
@@ -1,61 +0,0 @@
1
- """
2
- BioFlow - AI-Powered Drug Discovery Platform
3
- ==============================================
4
- Main application entry point.
5
- """
6
-
7
- import streamlit as st
8
- import sys
9
- import os
10
-
11
- # Setup path for imports
12
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
13
-
14
- from bioflow.ui.config import get_css
15
- from bioflow.ui.components import side_nav
16
- from bioflow.ui.pages import home, discovery, explorer, data, settings
17
-
18
-
19
- def main():
20
- """Main application."""
21
-
22
- # Page config
23
- st.set_page_config(
24
- page_title="BioFlow",
25
- page_icon="🧬",
26
- layout="wide",
27
- initial_sidebar_state="collapsed",
28
- )
29
-
30
- # Inject custom CSS
31
- st.markdown(get_css(), unsafe_allow_html=True)
32
-
33
- # Initialize session state
34
- if "current_page" not in st.session_state:
35
- st.session_state.current_page = "home"
36
-
37
- # Layout with left navigation
38
- nav_col, content_col = st.columns([1, 3.6], gap="large")
39
-
40
- with nav_col:
41
- selected = side_nav(active_page=st.session_state.current_page)
42
-
43
- if selected != st.session_state.current_page:
44
- st.session_state.current_page = selected
45
- st.rerun()
46
-
47
- with content_col:
48
- page_map = {
49
- "home": home.render,
50
- "discovery": discovery.render,
51
- "explorer": explorer.render,
52
- "data": data.render,
53
- "settings": settings.render,
54
- }
55
-
56
- render_fn = page_map.get(st.session_state.current_page, home.render)
57
- render_fn()
58
-
59
-
60
- if __name__ == "__main__":
61
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/components.py DELETED
@@ -1,481 +0,0 @@
1
- """
2
- BioFlow UI - Components Library
3
- ================================
4
- Reusable, modern UI components for Streamlit.
5
- """
6
-
7
- import streamlit as st
8
- from typing import List, Dict, Any, Optional, Callable
9
- import plotly.express as px
10
- import plotly.graph_objects as go
11
-
12
- # Import colors
13
- import sys
14
- import os
15
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
16
- from bioflow.ui.config import COLORS
17
-
18
-
19
- # === Navigation ===
20
-
21
- def side_nav(active_page: str = "home") -> str:
22
- """Left vertical navigation list. Returns the selected page key."""
23
-
24
- nav_items = [
25
- ("home", "🏠", "Home"),
26
- ("discovery", "🔬", "Discovery"),
27
- ("explorer", "🧬", "Explorer"),
28
- ("data", "📊", "Data"),
29
- ("settings", "⚙️", "Settings"),
30
- ]
31
-
32
- st.markdown(
33
- f"""
34
- <div class="nav-rail">
35
- <div class="nav-brand">
36
- <div class="nav-logo">🧬</div>
37
- <div class="nav-title">Bio<span>Flow</span></div>
38
- </div>
39
- <div class="nav-section">Navigation</div>
40
- </div>
41
- """,
42
- unsafe_allow_html=True,
43
- )
44
-
45
- label_map = {key: f"{icon} {label}" for key, icon, label in nav_items}
46
- options = [item[0] for item in nav_items]
47
-
48
- selected = st.radio(
49
- "Navigation",
50
- options=options,
51
- index=options.index(active_page),
52
- format_func=lambda x: label_map.get(x, x),
53
- key="nav_radio",
54
- label_visibility="collapsed",
55
- )
56
-
57
- return selected
58
-
59
-
60
- # === Page Structure ===
61
-
62
- def page_header(title: str, subtitle: str = "", icon: str = ""):
63
- """Page header with title and optional subtitle."""
64
- header_html = f"""
65
- <div style="margin-bottom: 2rem;">
66
- <h1 style="display: flex; align-items: center; gap: 0.75rem; margin: 0;">
67
- {f'<span style="font-size: 2rem;">{icon}</span>' if icon else ''}
68
- {title}
69
- </h1>
70
- {f'<p style="margin-top: 0.5rem; font-size: 1rem; color: {COLORS.text_muted};">{subtitle}</p>' if subtitle else ''}
71
- </div>
72
- """
73
- st.markdown(header_html, unsafe_allow_html=True)
74
-
75
-
76
- def section_header(title: str, icon: str = "", link_text: str = "", link_action: Optional[Callable] = None):
77
- """Section header with optional action link."""
78
- col1, col2 = st.columns([4, 1])
79
-
80
- with col1:
81
- st.markdown(f"""
82
- <div class="section-title">
83
- {f'<span>{icon}</span>' if icon else ''}
84
- {title}
85
- </div>
86
- """, unsafe_allow_html=True)
87
-
88
- with col2:
89
- if link_text:
90
- if st.button(link_text, key=f"section_{title}", use_container_width=True):
91
- if link_action:
92
- link_action()
93
-
94
-
95
- def divider():
96
- """Visual divider."""
97
- st.markdown('<div class="divider"></div>', unsafe_allow_html=True)
98
-
99
-
100
- def spacer(height: str = "1rem"):
101
- """Vertical spacer."""
102
- st.markdown(f'<div style="height: {height};"></div>', unsafe_allow_html=True)
103
-
104
-
105
- # === Metrics ===
106
-
107
- def metric_card(
108
- value: str,
109
- label: str,
110
- icon: str = "📊",
111
- change: Optional[str] = None,
112
- change_type: str = "up",
113
- color: str = COLORS.primary
114
- ):
115
- """Single metric card with icon and optional trend."""
116
- bg_color = color.replace(")", ", 0.15)").replace("rgb", "rgba") if "rgb" in color else f"{color}22"
117
- change_html = ""
118
- if change:
119
- arrow = "↑" if change_type == "up" else "↓"
120
- change_html = f'<div class="metric-change {change_type}">{arrow} {change}</div>'
121
-
122
- st.markdown(f"""
123
- <div class="metric">
124
- <div class="metric-icon" style="background: {bg_color}; color: {color};">
125
- {icon}
126
- </div>
127
- <div class="metric-value">{value}</div>
128
- <div class="metric-label">{label}</div>
129
- {change_html}
130
- </div>
131
- """, unsafe_allow_html=True)
132
-
133
-
134
- def metric_row(metrics: List[Dict[str, Any]]):
135
- """Row of metric cards."""
136
- cols = st.columns(len(metrics))
137
- for col, metric in zip(cols, metrics):
138
- with col:
139
- metric_card(**metric)
140
-
141
-
142
- # === Quick Actions ===
143
-
144
- def quick_action(icon: str, title: str, description: str, key: str) -> bool:
145
- """Single quick action card. Returns True if clicked."""
146
- clicked = st.button(
147
- f"{icon} {title}",
148
- key=key,
149
- use_container_width=True,
150
- help=description
151
- )
152
- return clicked
153
-
154
-
155
- def quick_actions_grid(actions: List[Dict[str, Any]], columns: int = 4) -> Optional[str]:
156
- """Grid of quick action cards. Returns clicked action key or None."""
157
- cols = st.columns(columns)
158
- clicked_key = None
159
-
160
- for i, action in enumerate(actions):
161
- with cols[i % columns]:
162
- st.markdown(f"""
163
- <div class="quick-action">
164
- <span class="quick-action-icon">{action['icon']}</span>
165
- <div class="quick-action-title">{action['title']}</div>
166
- <div class="quick-action-desc">{action.get('description', '')}</div>
167
- </div>
168
- """, unsafe_allow_html=True)
169
-
170
- if st.button("Select", key=action['key'], use_container_width=True):
171
- clicked_key = action['key']
172
-
173
- return clicked_key
174
-
175
-
176
- # === Pipeline Progress ===
177
-
178
- def pipeline_progress(steps: List[Dict[str, Any]]):
179
- """Visual pipeline with steps showing progress."""
180
- html = '<div class="pipeline">'
181
-
182
- for i, step in enumerate(steps):
183
- status = step.get('status', 'pending')
184
- icon = step.get('icon', str(i + 1))
185
- name = step.get('name', f'Step {i + 1}')
186
-
187
- # Display icon for completed steps
188
- if status == 'done':
189
- display = '✓'
190
- elif status == 'active':
191
- display = icon
192
- else:
193
- display = str(i + 1)
194
-
195
- html += f'''
196
- <div class="step">
197
- <div class="step-dot {status}">{display}</div>
198
- <span class="step-name">{name}</span>
199
- </div>
200
- '''
201
-
202
- # Add connecting line (except after last step)
203
- if i < len(steps) - 1:
204
- line_status = 'done' if status == 'done' else ''
205
- html += f'<div class="step-line {line_status}"></div>'
206
-
207
- html += '</div>'
208
- st.markdown(html, unsafe_allow_html=True)
209
-
210
-
211
- # === Results ===
212
-
213
- def result_card(
214
- title: str,
215
- score: float,
216
- properties: Dict[str, str] = None,
217
- badges: List[str] = None,
218
- key: str = ""
219
- ) -> bool:
220
- """Result card with score and properties. Returns True if clicked."""
221
-
222
- # Score color
223
- if score >= 0.8:
224
- score_class = "score-high"
225
- elif score >= 0.5:
226
- score_class = "score-med"
227
- else:
228
- score_class = "score-low"
229
-
230
- # Properties HTML
231
- props_html = ""
232
- if properties:
233
- props_html = '<div style="display: flex; gap: 1rem; margin-top: 0.75rem; flex-wrap: wrap;">'
234
- for k, v in properties.items():
235
- props_html += f'''
236
- <div style="font-size: 0.8125rem;">
237
- <span style="color: {COLORS.text_muted};">{k}:</span>
238
- <span style="color: {COLORS.text_secondary}; margin-left: 0.25rem;">{v}</span>
239
- </div>
240
- '''
241
- props_html += '</div>'
242
-
243
- # Badges HTML
244
- badges_html = ""
245
- if badges:
246
- badges_html = '<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">'
247
- for b in badges:
248
- badges_html += f'<span class="badge badge-primary">{b}</span>'
249
- badges_html += '</div>'
250
-
251
- st.markdown(f"""
252
- <div class="result">
253
- <div style="display: flex; justify-content: space-between; align-items: flex-start;">
254
- <div style="font-weight: 600; color: {COLORS.text_primary};">{title}</div>
255
- <div class="{score_class}" style="font-size: 1.25rem; font-weight: 700;">{score:.1%}</div>
256
- </div>
257
- {props_html}
258
- {badges_html}
259
- </div>
260
- """, unsafe_allow_html=True)
261
-
262
- return st.button("View Details", key=key, use_container_width=True) if key else False
263
-
264
-
265
- def results_list(results: List[Dict[str, Any]], empty_message: str = "No results found"):
266
- """List of result cards."""
267
- if not results:
268
- empty_state(icon="🔍", title="No Results", description=empty_message)
269
- return
270
-
271
- for i, result in enumerate(results):
272
- result_card(
273
- title=result.get('title', f'Result {i + 1}'),
274
- score=result.get('score', 0),
275
- properties=result.get('properties'),
276
- badges=result.get('badges'),
277
- key=f"result_{i}"
278
- )
279
- spacer("0.75rem")
280
-
281
-
282
- # === Charts ===
283
-
284
- def bar_chart(data: Dict[str, float], title: str = "", height: int = 300):
285
- """Styled bar chart."""
286
- fig = go.Figure(data=[
287
- go.Bar(
288
- x=list(data.keys()),
289
- y=list(data.values()),
290
- marker_color=COLORS.primary,
291
- marker_line_width=0,
292
- )
293
- ])
294
-
295
- fig.update_layout(
296
- title=title,
297
- paper_bgcolor='rgba(0,0,0,0)',
298
- plot_bgcolor='rgba(0,0,0,0)',
299
- font=dict(family="Inter", color=COLORS.text_secondary),
300
- height=height,
301
- margin=dict(l=40, r=20, t=40, b=40),
302
- xaxis=dict(
303
- showgrid=False,
304
- showline=True,
305
- linecolor=COLORS.border,
306
- ),
307
- yaxis=dict(
308
- showgrid=True,
309
- gridcolor=COLORS.border,
310
- showline=False,
311
- ),
312
- )
313
-
314
- st.plotly_chart(fig, use_container_width=True)
315
-
316
-
317
- def scatter_chart(x: List, y: List, labels: List = None, title: str = "", height: int = 400):
318
- """Styled scatter plot."""
319
- fig = go.Figure(data=[
320
- go.Scatter(
321
- x=x,
322
- y=y,
323
- mode='markers',
324
- marker=dict(
325
- size=10,
326
- color=COLORS.primary,
327
- opacity=0.7,
328
- ),
329
- text=labels,
330
- hovertemplate='<b>%{text}</b><br>X: %{x}<br>Y: %{y}<extra></extra>' if labels else None,
331
- )
332
- ])
333
-
334
- fig.update_layout(
335
- title=title,
336
- paper_bgcolor='rgba(0,0,0,0)',
337
- plot_bgcolor='rgba(0,0,0,0)',
338
- font=dict(family="Inter", color=COLORS.text_secondary),
339
- height=height,
340
- margin=dict(l=40, r=20, t=40, b=40),
341
- xaxis=dict(
342
- showgrid=True,
343
- gridcolor=COLORS.border,
344
- showline=True,
345
- linecolor=COLORS.border,
346
- ),
347
- yaxis=dict(
348
- showgrid=True,
349
- gridcolor=COLORS.border,
350
- showline=True,
351
- linecolor=COLORS.border,
352
- ),
353
- )
354
-
355
- st.plotly_chart(fig, use_container_width=True)
356
-
357
-
358
- def heatmap(data: List[List[float]], x_labels: List[str], y_labels: List[str], title: str = "", height: int = 400):
359
- """Styled heatmap."""
360
- fig = go.Figure(data=[
361
- go.Heatmap(
362
- z=data,
363
- x=x_labels,
364
- y=y_labels,
365
- colorscale=[
366
- [0, COLORS.bg_hover],
367
- [0.5, COLORS.primary],
368
- [1, COLORS.cyan],
369
- ],
370
- )
371
- ])
372
-
373
- fig.update_layout(
374
- title=title,
375
- paper_bgcolor='rgba(0,0,0,0)',
376
- plot_bgcolor='rgba(0,0,0,0)',
377
- font=dict(family="Inter", color=COLORS.text_secondary),
378
- height=height,
379
- margin=dict(l=80, r=20, t=40, b=60),
380
- )
381
-
382
- st.plotly_chart(fig, use_container_width=True)
383
-
384
-
385
- # === Data Display ===
386
-
387
- def data_table(data: List[Dict], columns: List[str] = None):
388
- """Styled data table."""
389
- import pandas as pd
390
- df = pd.DataFrame(data)
391
- if columns:
392
- df = df[columns]
393
- st.dataframe(df, use_container_width=True, hide_index=True)
394
-
395
-
396
- # === States ===
397
-
398
- def empty_state(icon: str = "📭", title: str = "No Data", description: str = ""):
399
- """Empty state placeholder."""
400
- st.markdown(f"""
401
- <div class="empty">
402
- <div class="empty-icon">{icon}</div>
403
- <div class="empty-title">{title}</div>
404
- <div class="empty-desc">{description}</div>
405
- </div>
406
- """, unsafe_allow_html=True)
407
-
408
-
409
- def loading_state(message: str = "Loading..."):
410
- """Loading state with spinner."""
411
- st.markdown(f"""
412
- <div class="loading">
413
- <div class="spinner"></div>
414
- <div class="loading-text">{message}</div>
415
- </div>
416
- """, unsafe_allow_html=True)
417
-
418
-
419
- # === Molecule Display ===
420
-
421
- def molecule_2d(smiles: str, size: int = 200):
422
- """Display 2D molecule structure from SMILES."""
423
- try:
424
- from rdkit import Chem
425
- from rdkit.Chem import Draw
426
- import base64
427
- from io import BytesIO
428
-
429
- mol = Chem.MolFromSmiles(smiles)
430
- if mol:
431
- img = Draw.MolToImage(mol, size=(size, size))
432
- buffered = BytesIO()
433
- img.save(buffered, format="PNG")
434
- img_str = base64.b64encode(buffered.getvalue()).decode()
435
-
436
- st.markdown(f"""
437
- <div class="mol-container">
438
- <img src="data:image/png;base64,{img_str}" alt="Molecule" style="max-width: 100%; height: auto;">
439
- </div>
440
- """, unsafe_allow_html=True)
441
- else:
442
- st.warning("Invalid SMILES")
443
- except ImportError:
444
- st.info(f"SMILES: `{smiles}`")
445
-
446
-
447
- # === Evidence & Links ===
448
-
449
- def evidence_row(items: List[Dict[str, str]]):
450
- """Row of evidence/source links."""
451
- html = '<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem;">'
452
- for item in items:
453
- icon = item.get('icon', '📄')
454
- label = item.get('label', 'Source')
455
- url = item.get('url', '#')
456
- html += f'''
457
- <a href="{url}" target="_blank" class="evidence">
458
- <span>{icon}</span>
459
- <span>{label}</span>
460
- </a>
461
- '''
462
- html += '</div>'
463
- st.markdown(html, unsafe_allow_html=True)
464
-
465
-
466
- # === Badges ===
467
-
468
- def badge(text: str, variant: str = "primary"):
469
- """Inline badge component."""
470
- st.markdown(f'<span class="badge badge-{variant}">{text}</span>', unsafe_allow_html=True)
471
-
472
-
473
- def badge_row(badges: List[Dict[str, str]]):
474
- """Row of badges."""
475
- html = '<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">'
476
- for b in badges:
477
- text = b.get('text', '')
478
- variant = b.get('variant', 'primary')
479
- html += f'<span class="badge badge-{variant}">{text}</span>'
480
- html += '</div>'
481
- st.markdown(html, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/config.py DELETED
@@ -1,583 +0,0 @@
1
- """
2
- BioFlow UI - Modern Design System
3
- ==================================
4
- Clean, minimal, and highly usable interface.
5
- """
6
-
7
- from dataclasses import dataclass
8
-
9
-
10
- @dataclass
11
- class Colors:
12
- """Color palette - Modern dark theme."""
13
- # Primary
14
- primary: str = "#8B5CF6"
15
- primary_hover: str = "#A78BFA"
16
- primary_muted: str = "rgba(139, 92, 246, 0.15)"
17
-
18
- # Accents
19
- cyan: str = "#22D3EE"
20
- emerald: str = "#34D399"
21
- amber: str = "#FBBF24"
22
- rose: str = "#FB7185"
23
-
24
- # Backgrounds
25
- bg_app: str = "#0C0E14"
26
- bg_surface: str = "#14161E"
27
- bg_elevated: str = "#1C1F2B"
28
- bg_hover: str = "#252836"
29
-
30
- # Text
31
- text_primary: str = "#F8FAFC"
32
- text_secondary: str = "#A1A7BB"
33
- text_muted: str = "#6B7280"
34
-
35
- # Borders
36
- border: str = "#2A2D3A"
37
- border_hover: str = "#3F4354"
38
-
39
- # Status
40
- success: str = "#10B981"
41
- warning: str = "#F59E0B"
42
- error: str = "#EF4444"
43
- info: str = "#3B82F6"
44
-
45
-
46
- COLORS = Colors()
47
-
48
-
49
- def get_css() -> str:
50
- """Minimalist, professional CSS using string concatenation to avoid f-string issues."""
51
-
52
- css = """
53
- <style>
54
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
55
-
56
- :root {
57
- --primary: """ + COLORS.primary + """;
58
- --bg-app: """ + COLORS.bg_app + """;
59
- --bg-surface: """ + COLORS.bg_surface + """;
60
- --text: """ + COLORS.text_primary + """;
61
- --text-muted: """ + COLORS.text_muted + """;
62
- --border: """ + COLORS.border + """;
63
- --radius: 12px;
64
- --transition: 150ms ease;
65
- }
66
-
67
- .stApp {
68
- background: """ + COLORS.bg_app + """;
69
- font-family: 'Inter', sans-serif;
70
- }
71
-
72
- #MainMenu, footer, header { visibility: hidden; }
73
- .stDeployButton { display: none; }
74
-
75
- ::-webkit-scrollbar { width: 6px; height: 6px; }
76
- ::-webkit-scrollbar-track { background: transparent; }
77
- ::-webkit-scrollbar-thumb { background: """ + COLORS.border + """; border-radius: 3px; }
78
- ::-webkit-scrollbar-thumb:hover { background: """ + COLORS.border_hover + """; }
79
-
80
- section[data-testid="stSidebar"] { display: none !important; }
81
-
82
- h1, h2, h3 {
83
- font-weight: 600;
84
- color: """ + COLORS.text_primary + """;
85
- letter-spacing: -0.025em;
86
- }
87
-
88
- h1 { font-size: 1.875rem; margin-bottom: 0.5rem; }
89
- h2 { font-size: 1.5rem; }
90
- h3 { font-size: 1.125rem; }
91
-
92
- p { color: """ + COLORS.text_secondary + """; line-height: 1.6; }
93
-
94
- .card {
95
- background: """ + COLORS.bg_surface + """;
96
- border: 1px solid """ + COLORS.border + """;
97
- border-radius: var(--radius);
98
- padding: 1.25rem;
99
- }
100
-
101
- .metric {
102
- background: """ + COLORS.bg_surface + """;
103
- border: 1px solid """ + COLORS.border + """;
104
- border-radius: var(--radius);
105
- padding: 1.25rem;
106
- transition: border-color var(--transition);
107
- }
108
-
109
- .metric:hover { border-color: """ + COLORS.primary + """; }
110
-
111
- .metric-icon {
112
- width: 44px;
113
- height: 44px;
114
- border-radius: 10px;
115
- display: flex;
116
- align-items: center;
117
- justify-content: center;
118
- font-size: 1.375rem;
119
- margin-bottom: 1rem;
120
- }
121
-
122
- .metric-value {
123
- font-size: 2rem;
124
- font-weight: 700;
125
- color: """ + COLORS.text_primary + """;
126
- line-height: 1;
127
- }
128
-
129
- .metric-label {
130
- font-size: 0.875rem;
131
- color: """ + COLORS.text_muted + """;
132
- margin-top: 0.375rem;
133
- }
134
-
135
- .metric-change {
136
- display: inline-flex;
137
- align-items: center;
138
- font-size: 0.75rem;
139
- font-weight: 500;
140
- padding: 0.25rem 0.5rem;
141
- border-radius: 6px;
142
- margin-top: 0.5rem;
143
- }
144
-
145
- .metric-change.up { background: rgba(16, 185, 129, 0.15); color: """ + COLORS.success + """; }
146
- .metric-change.down { background: rgba(239, 68, 68, 0.15); color: """ + COLORS.error + """; }
147
-
148
- .stButton > button {
149
- font-family: 'Inter', sans-serif;
150
- font-weight: 500;
151
- font-size: 0.875rem;
152
- border-radius: 8px;
153
- padding: 0.625rem 1.25rem;
154
- transition: all var(--transition);
155
- border: none;
156
- }
157
-
158
- .stTextInput input,
159
- .stTextArea textarea,
160
- .stSelectbox > div > div {
161
- background: """ + COLORS.bg_app + """ !important;
162
- border: 1px solid """ + COLORS.border + """ !important;
163
- border-radius: 10px !important;
164
- color: """ + COLORS.text_primary + """ !important;
165
- font-family: 'Inter', sans-serif !important;
166
- }
167
-
168
- .stTextInput input:focus,
169
- .stTextArea textarea:focus {
170
- border-color: """ + COLORS.primary + """ !important;
171
- box-shadow: 0 0 0 3px """ + COLORS.primary_muted + """ !important;
172
- }
173
-
174
- .stTabs [data-baseweb="tab-list"] {
175
- gap: 0;
176
- background: """ + COLORS.bg_surface + """;
177
- border-radius: 10px;
178
- padding: 4px;
179
- border: 1px solid """ + COLORS.border + """;
180
- }
181
-
182
- .stTabs [data-baseweb="tab"] {
183
- height: auto;
184
- padding: 0.625rem 1.25rem;
185
- border-radius: 8px;
186
- font-weight: 500;
187
- font-size: 0.875rem;
188
- color: """ + COLORS.text_muted + """;
189
- background: transparent;
190
- }
191
-
192
- .stTabs [aria-selected="true"] {
193
- background: """ + COLORS.primary + """ !important;
194
- color: white !important;
195
- }
196
-
197
- .stTabs [data-baseweb="tab-highlight"],
198
- .stTabs [data-baseweb="tab-border"] { display: none; }
199
-
200
- .pipeline {
201
- display: flex;
202
- align-items: center;
203
- background: """ + COLORS.bg_surface + """;
204
- border: 1px solid """ + COLORS.border + """;
205
- border-radius: var(--radius);
206
- padding: 1.5rem;
207
- gap: 0;
208
- }
209
-
210
- .step {
211
- display: flex;
212
- flex-direction: column;
213
- align-items: center;
214
- gap: 0.5rem;
215
- flex: 1;
216
- }
217
-
218
- .step-dot {
219
- width: 44px;
220
- height: 44px;
221
- border-radius: 50%;
222
- display: flex;
223
- align-items: center;
224
- justify-content: center;
225
- font-size: 1.125rem;
226
- font-weight: 600;
227
- transition: all var(--transition);
228
- }
229
-
230
- .step-dot.pending {
231
- background: """ + COLORS.bg_hover + """;
232
- color: """ + COLORS.text_muted + """;
233
- border: 2px dashed """ + COLORS.border_hover + """;
234
- }
235
-
236
- .step-dot.active {
237
- background: """ + COLORS.primary + """;
238
- color: white;
239
- box-shadow: 0 0 24px rgba(139, 92, 246, 0.5);
240
- }
241
-
242
- .step-dot.done {
243
- background: """ + COLORS.emerald + """;
244
- color: white;
245
- }
246
-
247
- .step-name {
248
- font-size: 0.75rem;
249
- font-weight: 500;
250
- color: """ + COLORS.text_muted + """;
251
- }
252
-
253
- .step-line {
254
- flex: 0.6;
255
- height: 2px;
256
- background: """ + COLORS.border + """;
257
- }
258
-
259
- .step-line.done { background: """ + COLORS.emerald + """; }
260
-
261
- .result {
262
- background: """ + COLORS.bg_surface + """;
263
- border: 1px solid """ + COLORS.border + """;
264
- border-radius: var(--radius);
265
- padding: 1.25rem;
266
- transition: all var(--transition);
267
- cursor: pointer;
268
- }
269
-
270
- .result:hover {
271
- border-color: """ + COLORS.primary + """;
272
- transform: translateY(-2px);
273
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
274
- }
275
-
276
- .score-high { color: """ + COLORS.emerald + """; }
277
- .score-med { color: """ + COLORS.amber + """; }
278
- .score-low { color: """ + COLORS.rose + """; }
279
-
280
- .badge {
281
- display: inline-flex;
282
- align-items: center;
283
- padding: 0.25rem 0.625rem;
284
- border-radius: 6px;
285
- font-size: 0.6875rem;
286
- font-weight: 600;
287
- text-transform: uppercase;
288
- }
289
-
290
- .badge-primary { background: """ + COLORS.primary_muted + """; color: """ + COLORS.primary + """; }
291
- .badge-success { background: rgba(16, 185, 129, 0.15); color: """ + COLORS.success + """; }
292
- .badge-warning { background: rgba(245, 158, 11, 0.15); color: """ + COLORS.warning + """; }
293
- .badge-error { background: rgba(239, 68, 68, 0.15); color: """ + COLORS.error + """; }
294
-
295
- .quick-action {
296
- background: """ + COLORS.bg_surface + """;
297
- border: 1px solid """ + COLORS.border + """;
298
- border-radius: var(--radius);
299
- padding: 1.5rem;
300
- text-align: center;
301
- cursor: pointer;
302
- transition: all var(--transition);
303
- }
304
-
305
- .quick-action:hover {
306
- border-color: """ + COLORS.primary + """;
307
- transform: translateY(-4px);
308
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
309
- }
310
-
311
- .quick-action-icon {
312
- font-size: 2.5rem;
313
- margin-bottom: 0.75rem;
314
- display: block;
315
- }
316
-
317
- .quick-action-title {
318
- font-size: 0.9375rem;
319
- font-weight: 600;
320
- color: """ + COLORS.text_primary + """;
321
- }
322
-
323
- .quick-action-desc {
324
- font-size: 0.8125rem;
325
- color: """ + COLORS.text_muted + """;
326
- margin-top: 0.25rem;
327
- }
328
-
329
- .section-header {
330
- display: flex;
331
- align-items: center;
332
- justify-content: space-between;
333
- margin-bottom: 1rem;
334
- }
335
-
336
- .section-title {
337
- font-size: 1rem;
338
- font-weight: 600;
339
- color: """ + COLORS.text_primary + """;
340
- display: flex;
341
- align-items: center;
342
- gap: 0.5rem;
343
- }
344
-
345
- .section-link {
346
- font-size: 0.8125rem;
347
- color: """ + COLORS.primary + """;
348
- cursor: pointer;
349
- }
350
-
351
- .section-link:hover { text-decoration: underline; }
352
-
353
- .empty {
354
- display: flex;
355
- flex-direction: column;
356
- align-items: center;
357
- justify-content: center;
358
- padding: 4rem 2rem;
359
- text-align: center;
360
- }
361
-
362
- .empty-icon { font-size: 3.5rem; margin-bottom: 1rem; opacity: 0.4; }
363
- .empty-title { font-size: 1.125rem; font-weight: 600; color: """ + COLORS.text_primary + """; }
364
- .empty-desc { font-size: 0.9375rem; color: """ + COLORS.text_muted + """; max-width: 320px; margin-top: 0.5rem; }
365
-
366
- .loading {
367
- display: flex;
368
- flex-direction: column;
369
- align-items: center;
370
- padding: 3rem;
371
- }
372
-
373
- .spinner {
374
- width: 40px;
375
- height: 40px;
376
- border: 3px solid """ + COLORS.border + """;
377
- border-top-color: """ + COLORS.primary + """;
378
- border-radius: 50%;
379
- animation: spin 0.8s linear infinite;
380
- }
381
-
382
- @keyframes spin { to { transform: rotate(360deg); } }
383
-
384
- .loading-text {
385
- margin-top: 1rem;
386
- color: """ + COLORS.text_muted + """;
387
- font-size: 0.875rem;
388
- }
389
-
390
- .stProgress > div > div > div {
391
- background: linear-gradient(90deg, """ + COLORS.primary + """ 0%, """ + COLORS.cyan + """ 100%);
392
- border-radius: 4px;
393
- }
394
-
395
- .stProgress > div > div {
396
- background: """ + COLORS.bg_hover + """;
397
- border-radius: 4px;
398
- }
399
-
400
- .divider {
401
- height: 1px;
402
- background: """ + COLORS.border + """;
403
- margin: 1.5rem 0;
404
- }
405
-
406
- .mol-container {
407
- background: white;
408
- border-radius: 10px;
409
- padding: 0.75rem;
410
- display: flex;
411
- align-items: center;
412
- justify-content: center;
413
- }
414
-
415
- .evidence {
416
- display: inline-flex;
417
- align-items: center;
418
- gap: 0.375rem;
419
- padding: 0.5rem 0.75rem;
420
- background: """ + COLORS.bg_app + """;
421
- border: 1px solid """ + COLORS.border + """;
422
- border-radius: 8px;
423
- font-size: 0.8125rem;
424
- color: """ + COLORS.text_secondary + """;
425
- transition: all var(--transition);
426
- text-decoration: none;
427
- }
428
-
429
- .evidence:hover {
430
- border-color: """ + COLORS.primary + """;
431
- color: """ + COLORS.primary + """;
432
- }
433
-
434
- .stAlert { border-radius: 10px; border: none; }
435
-
436
- .stDataFrame {
437
- border-radius: var(--radius);
438
- overflow: hidden;
439
- border: 1px solid """ + COLORS.border + """;
440
- }
441
-
442
- .block-container {
443
- padding-top: 1.25rem;
444
- }
445
-
446
- .nav-rail {
447
- position: sticky;
448
- top: 1rem;
449
- display: flex;
450
- flex-direction: column;
451
- gap: 0.75rem;
452
- padding: 1rem;
453
- background: """ + COLORS.bg_surface + """;
454
- border: 1px solid """ + COLORS.border + """;
455
- border-radius: 16px;
456
- margin-bottom: 1rem;
457
- }
458
-
459
- .nav-brand {
460
- display: flex;
461
- align-items: center;
462
- gap: 0.75rem;
463
- padding-bottom: 0.5rem;
464
- border-bottom: 1px solid """ + COLORS.border + """;
465
- }
466
-
467
- .nav-logo { font-size: 1.5rem; }
468
-
469
- .nav-title {
470
- font-size: 1.1rem;
471
- font-weight: 700;
472
- color: """ + COLORS.text_primary + """;
473
- }
474
-
475
- .nav-title span {
476
- background: linear-gradient(135deg, """ + COLORS.primary + """ 0%, """ + COLORS.cyan + """ 100%);
477
- -webkit-background-clip: text;
478
- -webkit-text-fill-color: transparent;
479
- }
480
-
481
- .nav-section {
482
- font-size: 0.75rem;
483
- text-transform: uppercase;
484
- letter-spacing: 0.08em;
485
- color: """ + COLORS.text_muted + """;
486
- }
487
-
488
- div[data-testid="stRadio"] {
489
- background: """ + COLORS.bg_surface + """;
490
- border: 1px solid """ + COLORS.border + """;
491
- border-radius: 16px;
492
- padding: 0.75rem;
493
- }
494
-
495
- div[data-testid="stRadio"] div[role="radiogroup"] {
496
- display: flex;
497
- flex-direction: column;
498
- gap: 0.5rem;
499
- margin-top: 0.25rem;
500
- }
501
-
502
- div[data-testid="stRadio"] input {
503
- display: none !important;
504
- }
505
-
506
- div[data-testid="stRadio"] label {
507
- background: """ + COLORS.bg_app + """;
508
- border: 1px solid """ + COLORS.border + """;
509
- border-radius: 12px;
510
- padding: 0.65rem 0.9rem;
511
- font-weight: 500;
512
- color: """ + COLORS.text_secondary + """;
513
- transition: all var(--transition);
514
- margin: 0 !important;
515
- }
516
-
517
- div[data-testid="stRadio"] label:hover {
518
- border-color: """ + COLORS.primary + """;
519
- color: """ + COLORS.text_primary + """;
520
- }
521
-
522
- div[data-testid="stRadio"] label:has(input:checked) {
523
- background: """ + COLORS.primary + """;
524
- border-color: """ + COLORS.primary + """;
525
- color: white;
526
- box-shadow: 0 8px 20px rgba(139, 92, 246, 0.25);
527
- }
528
-
529
- .hero {
530
- position: relative;
531
- background: linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(34, 211, 238, 0.08) 100%);
532
- border: 1px solid """ + COLORS.border + """;
533
- border-radius: 20px;
534
- padding: 2.75rem;
535
- overflow: hidden;
536
- }
537
-
538
- .hero-badge {
539
- display: inline-flex;
540
- align-items: center;
541
- gap: 0.5rem;
542
- padding: 0.35rem 0.75rem;
543
- border-radius: 999px;
544
- background: """ + COLORS.primary_muted + """;
545
- color: """ + COLORS.primary + """;
546
- font-size: 0.75rem;
547
- font-weight: 600;
548
- text-transform: uppercase;
549
- letter-spacing: 0.08em;
550
- }
551
-
552
- .hero-title {
553
- font-size: 2.25rem;
554
- font-weight: 700;
555
- color: """ + COLORS.text_primary + """;
556
- margin-top: 1rem;
557
- line-height: 1.1;
558
- }
559
-
560
- .hero-subtitle {
561
- font-size: 1rem;
562
- color: """ + COLORS.text_muted + """;
563
- margin-top: 0.75rem;
564
- max-width: 560px;
565
- }
566
-
567
- .hero-actions {
568
- display: flex;
569
- gap: 0.75rem;
570
- margin-top: 1.5rem;
571
- flex-wrap: wrap;
572
- }
573
-
574
- .hero-card {
575
- background: """ + COLORS.bg_surface + """;
576
- border: 1px solid """ + COLORS.border + """;
577
- border-radius: 16px;
578
- padding: 1.5rem;
579
- }
580
- </style>
581
- """
582
-
583
- return css
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/pages/__init__.py DELETED
@@ -1,5 +0,0 @@
1
- """Page exports."""
2
-
3
- from bioflow.ui.pages import home, discovery, explorer, data, settings
4
-
5
- __all__ = ["home", "discovery", "explorer", "data", "settings"]
 
 
 
 
 
 
bioflow/ui/pages/data.py DELETED
@@ -1,163 +0,0 @@
1
- """
2
- BioFlow - Data Page
3
- ===================
4
- Data management and upload.
5
- """
6
-
7
- import streamlit as st
8
- import sys
9
- import os
10
- import pandas as pd
11
- import numpy as np
12
-
13
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
14
-
15
- from bioflow.ui.components import (
16
- page_header, section_header, divider, spacer,
17
- metric_card, data_table, empty_state
18
- )
19
- from bioflow.ui.config import COLORS
20
-
21
-
22
- def render():
23
- """Render data page."""
24
-
25
- page_header("Data Management", "Upload, manage, and organize your datasets", "📊")
26
-
27
- # Stats Row
28
- cols = st.columns(4)
29
-
30
- with cols[0]:
31
- metric_card("5", "Datasets", "📁", color=COLORS.primary)
32
- with cols[1]:
33
- metric_card("24.5K", "Molecules", "🧪", color=COLORS.cyan)
34
- with cols[2]:
35
- metric_card("1.2K", "Proteins", "🧬", color=COLORS.emerald)
36
- with cols[3]:
37
- metric_card("156 MB", "Storage Used", "💾", color=COLORS.amber)
38
-
39
- spacer("2rem")
40
-
41
- # Tabs
42
- tabs = st.tabs(["📁 Datasets", "📤 Upload", "🔧 Processing"])
43
-
44
- with tabs[0]:
45
- section_header("Your Datasets", "📁")
46
-
47
- # Dataset list
48
- datasets = [
49
- {"name": "DrugBank Compounds", "type": "Molecules", "count": "12,450", "size": "45.2 MB", "updated": "2024-01-15"},
50
- {"name": "ChEMBL Kinase Inhibitors", "type": "Molecules", "count": "8,234", "size": "32.1 MB", "updated": "2024-01-10"},
51
- {"name": "Custom Protein Targets", "type": "Proteins", "count": "1,245", "size": "78.5 MB", "updated": "2024-01-08"},
52
- ]
53
-
54
- for ds in datasets:
55
- st.markdown(f"""
56
- <div class="card" style="margin-bottom: 0.75rem;">
57
- <div style="display: flex; justify-content: space-between; align-items: center;">
58
- <div>
59
- <div style="font-weight: 600; color: {COLORS.text_primary};">{ds["name"]}</div>
60
- <div style="display: flex; gap: 1.5rem; margin-top: 0.5rem;">
61
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">
62
- <span style="color: {COLORS.primary};">●</span> {ds["type"]}
63
- </span>
64
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">{ds["count"]} items</span>
65
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">{ds["size"]}</span>
66
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">Updated: {ds["updated"]}</span>
67
- </div>
68
- </div>
69
- <div style="display: flex; gap: 0.5rem;">
70
- <span class="badge badge-primary">{ds["type"]}</span>
71
- </div>
72
- </div>
73
- </div>
74
- """, unsafe_allow_html=True)
75
-
76
- # Action buttons
77
- btn_cols = st.columns([1, 1, 1, 4])
78
- with btn_cols[0]:
79
- st.button("View", key=f"view_{ds['name']}", use_container_width=True)
80
- with btn_cols[1]:
81
- st.button("Export", key=f"export_{ds['name']}", use_container_width=True)
82
- with btn_cols[2]:
83
- st.button("Delete", key=f"delete_{ds['name']}", use_container_width=True)
84
-
85
- spacer("0.5rem")
86
-
87
- with tabs[1]:
88
- section_header("Upload New Data", "📤")
89
-
90
- # Upload area
91
- st.markdown(f"""
92
- <div style="
93
- border: 2px dashed {COLORS.border};
94
- border-radius: 16px;
95
- padding: 3rem;
96
- text-align: center;
97
- background: {COLORS.bg_surface};
98
- ">
99
- <div style="font-size: 3rem; margin-bottom: 1rem;">📁</div>
100
- <div style="font-size: 1.125rem; font-weight: 600; color: {COLORS.text_primary};">
101
- Drag & drop files here
102
- </div>
103
- <div style="font-size: 0.875rem; color: {COLORS.text_muted}; margin-top: 0.5rem;">
104
- or click to browse
105
- </div>
106
- <div style="font-size: 0.75rem; color: {COLORS.text_muted}; margin-top: 1rem;">
107
- Supports: CSV, SDF, FASTA, PDB, JSON
108
- </div>
109
- </div>
110
- """, unsafe_allow_html=True)
111
-
112
- uploaded_file = st.file_uploader(
113
- "Upload file",
114
- type=["csv", "sdf", "fasta", "pdb", "json"],
115
- label_visibility="collapsed"
116
- )
117
-
118
- if uploaded_file:
119
- st.success(f"✓ File uploaded: {uploaded_file.name}")
120
-
121
- col1, col2 = st.columns(2)
122
- with col1:
123
- dataset_name = st.text_input("Dataset Name", value=uploaded_file.name.split('.')[0])
124
- with col2:
125
- data_type = st.selectbox("Data Type", ["Molecules", "Proteins", "Text"])
126
-
127
- if st.button("Process & Import", type="primary", use_container_width=True):
128
- with st.spinner("Processing..."):
129
- import time
130
- time.sleep(2)
131
- st.success("✓ Dataset imported successfully!")
132
-
133
- with tabs[2]:
134
- section_header("Data Processing", "🔧")
135
-
136
- st.markdown(f"""
137
- <div class="card">
138
- <div style="font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 0.75rem;">
139
- Available Operations
140
- </div>
141
- </div>
142
- """, unsafe_allow_html=True)
143
-
144
- operations = [
145
- {"icon": "🧹", "name": "Clean & Validate", "desc": "Remove duplicates, fix invalid structures"},
146
- {"icon": "🔢", "name": "Compute Descriptors", "desc": "Calculate molecular properties and fingerprints"},
147
- {"icon": "🧠", "name": "Generate Embeddings", "desc": "Create vector representations using AI models"},
148
- {"icon": "🔗", "name": "Merge Datasets", "desc": "Combine multiple datasets with deduplication"},
149
- ]
150
-
151
- for op in operations:
152
- st.markdown(f"""
153
- <div class="quick-action" style="margin-bottom: 0.75rem; text-align: left;">
154
- <div style="display: flex; align-items: center; gap: 1rem;">
155
- <span style="font-size: 1.5rem;">{op["icon"]}</span>
156
- <div>
157
- <div style="font-weight: 600; color: {COLORS.text_primary};">{op["name"]}</div>
158
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted};">{op["desc"]}</div>
159
- </div>
160
- </div>
161
- </div>
162
- """, unsafe_allow_html=True)
163
- st.button(f"Run {op['name']}", key=f"op_{op['name']}", use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/pages/discovery.py DELETED
@@ -1,165 +0,0 @@
1
- """
2
- BioFlow - Discovery Page
3
- ========================
4
- Drug discovery pipeline interface.
5
- """
6
-
7
- import streamlit as st
8
- import sys
9
- import os
10
-
11
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
12
-
13
- from bioflow.ui.components import (
14
- page_header, section_header, divider, spacer,
15
- pipeline_progress, bar_chart, empty_state, loading_state
16
- )
17
- from bioflow.ui.config import COLORS
18
-
19
-
20
- def render():
21
- """Render discovery page."""
22
-
23
- page_header("Drug Discovery", "Search for drug candidates with AI-powered analysis", "🔬")
24
-
25
- # Query Input Section
26
- st.markdown(f"""
27
- <div class="card" style="margin-bottom: 1.5rem;">
28
- <div style="font-size: 0.875rem; font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 0.75rem;">
29
- Search Query
30
- </div>
31
- </div>
32
- """, unsafe_allow_html=True)
33
-
34
- col1, col2 = st.columns([3, 1])
35
-
36
- with col1:
37
- query = st.text_area(
38
- "Query",
39
- placeholder="Enter a natural language query, SMILES string, or FASTA sequence...",
40
- height=100,
41
- label_visibility="collapsed"
42
- )
43
-
44
- with col2:
45
- st.selectbox("Search Type", ["Similarity", "Binding Affinity", "Properties"], label_visibility="collapsed")
46
- st.selectbox("Database", ["All", "DrugBank", "ChEMBL", "ZINC"], label_visibility="collapsed")
47
- search_clicked = st.button("🔍 Search", type="primary", use_container_width=True)
48
-
49
- spacer("1.5rem")
50
-
51
- # Pipeline Progress
52
- section_header("Pipeline Status", "🔄")
53
-
54
- if "discovery_step" not in st.session_state:
55
- st.session_state.discovery_step = 0
56
-
57
- steps = [
58
- {"name": "Input", "status": "done" if st.session_state.discovery_step > 0 else "active"},
59
- {"name": "Encode", "status": "done" if st.session_state.discovery_step > 1 else ("active" if st.session_state.discovery_step == 1 else "pending")},
60
- {"name": "Search", "status": "done" if st.session_state.discovery_step > 2 else ("active" if st.session_state.discovery_step == 2 else "pending")},
61
- {"name": "Predict", "status": "done" if st.session_state.discovery_step > 3 else ("active" if st.session_state.discovery_step == 3 else "pending")},
62
- {"name": "Results", "status": "active" if st.session_state.discovery_step == 4 else "pending"},
63
- ]
64
-
65
- pipeline_progress(steps)
66
-
67
- spacer("2rem")
68
- divider()
69
- spacer("2rem")
70
-
71
- # Results Section
72
- section_header("Results", "🎯")
73
-
74
- if search_clicked and query:
75
- st.session_state.discovery_step = 4
76
- st.session_state.discovery_query = query
77
-
78
- if st.session_state.discovery_step >= 4:
79
- # Show results
80
- tabs = st.tabs(["Top Candidates", "Property Analysis", "Evidence"])
81
-
82
- with tabs[0]:
83
- # Results list
84
- results = [
85
- {"name": "Candidate A", "score": 0.95, "mw": "342.4", "logp": "2.1", "hbd": "2"},
86
- {"name": "Candidate B", "score": 0.89, "mw": "298.3", "logp": "1.8", "hbd": "3"},
87
- {"name": "Candidate C", "score": 0.82, "mw": "415.5", "logp": "3.2", "hbd": "1"},
88
- {"name": "Candidate D", "score": 0.76, "mw": "267.3", "logp": "1.5", "hbd": "2"},
89
- {"name": "Candidate E", "score": 0.71, "mw": "389.4", "logp": "2.8", "hbd": "2"},
90
- ]
91
-
92
- for r in results:
93
- score_color = COLORS.emerald if r["score"] >= 0.8 else (COLORS.amber if r["score"] >= 0.5 else COLORS.rose)
94
- st.markdown(f"""
95
- <div class="result">
96
- <div style="display: flex; justify-content: space-between; align-items: flex-start;">
97
- <div>
98
- <div style="font-weight: 600; color: {COLORS.text_primary};">{r["name"]}</div>
99
- <div style="display: flex; gap: 1rem; margin-top: 0.5rem;">
100
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">MW: {r["mw"]}</span>
101
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">LogP: {r["logp"]}</span>
102
- <span style="font-size: 0.8125rem; color: {COLORS.text_muted};">HBD: {r["hbd"]}</span>
103
- </div>
104
- </div>
105
- <div style="font-size: 1.5rem; font-weight: 700; color: {score_color};">{r["score"]:.0%}</div>
106
- </div>
107
- </div>
108
- """, unsafe_allow_html=True)
109
- spacer("0.75rem")
110
-
111
- with tabs[1]:
112
- # Property distribution
113
- col1, col2 = st.columns(2)
114
-
115
- with col1:
116
- bar_chart(
117
- {"<200": 5, "200-300": 12, "300-400": 8, "400-500": 3, ">500": 2},
118
- title="Molecular Weight Distribution",
119
- height=250
120
- )
121
-
122
- with col2:
123
- bar_chart(
124
- {"<1": 4, "1-2": 10, "2-3": 8, "3-4": 5, ">4": 3},
125
- title="LogP Distribution",
126
- height=250
127
- )
128
-
129
- with tabs[2]:
130
- # Evidence
131
- st.markdown(f"""
132
- <div class="card">
133
- <div style="font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 1rem;">
134
- Related Literature
135
- </div>
136
- </div>
137
- """, unsafe_allow_html=True)
138
-
139
- papers = [
140
- {"title": "Novel therapeutic targets for cancer treatment", "year": "2024", "journal": "Nature Medicine"},
141
- {"title": "Molecular docking studies of kinase inhibitors", "year": "2023", "journal": "J. Med. Chem."},
142
- {"title": "AI-driven drug discovery approaches", "year": "2024", "journal": "Drug Discovery Today"},
143
- ]
144
-
145
- for p in papers:
146
- st.markdown(f"""
147
- <div style="
148
- padding: 1rem;
149
- border: 1px solid {COLORS.border};
150
- border-radius: 8px;
151
- margin-bottom: 0.75rem;
152
- ">
153
- <div style="font-weight: 500; color: {COLORS.text_primary};">{p["title"]}</div>
154
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted}; margin-top: 0.25rem;">
155
- {p["journal"]} • {p["year"]}
156
- </div>
157
- </div>
158
- """, unsafe_allow_html=True)
159
-
160
- else:
161
- empty_state(
162
- "🔍",
163
- "No Results Yet",
164
- "Enter a query and click Search to find drug candidates"
165
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/pages/explorer.py DELETED
@@ -1,127 +0,0 @@
1
- """
2
- BioFlow - Explorer Page
3
- =======================
4
- Data exploration and visualization.
5
- """
6
-
7
- import streamlit as st
8
- import sys
9
- import os
10
- import numpy as np
11
-
12
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
13
-
14
- from bioflow.ui.components import (
15
- page_header, section_header, divider, spacer,
16
- scatter_chart, heatmap, metric_card, empty_state
17
- )
18
- from bioflow.ui.config import COLORS
19
-
20
-
21
- def render():
22
- """Render explorer page."""
23
-
24
- page_header("Data Explorer", "Visualize molecular embeddings and relationships", "🧬")
25
-
26
- # Controls
27
- col1, col2, col3, col4 = st.columns(4)
28
-
29
- with col1:
30
- dataset = st.selectbox("Dataset", ["DrugBank", "ChEMBL", "ZINC", "Custom"])
31
- with col2:
32
- viz_type = st.selectbox("Visualization", ["UMAP", "t-SNE", "PCA"])
33
- with col3:
34
- color_by = st.selectbox("Color by", ["Activity", "MW", "LogP", "Cluster"])
35
- with col4:
36
- st.write("") # Spacing
37
- st.write("")
38
- if st.button("🔄 Refresh", use_container_width=True):
39
- st.rerun()
40
-
41
- spacer("1.5rem")
42
-
43
- # Main visualization area
44
- col_viz, col_details = st.columns([2, 1])
45
-
46
- with col_viz:
47
- section_header("Embedding Space", "🗺️")
48
-
49
- # Generate sample data
50
- np.random.seed(42)
51
- n_points = 200
52
-
53
- # Create clusters
54
- cluster1_x = np.random.normal(2, 0.8, n_points // 4)
55
- cluster1_y = np.random.normal(3, 0.8, n_points // 4)
56
-
57
- cluster2_x = np.random.normal(-2, 1, n_points // 4)
58
- cluster2_y = np.random.normal(-1, 1, n_points // 4)
59
-
60
- cluster3_x = np.random.normal(4, 0.6, n_points // 4)
61
- cluster3_y = np.random.normal(-2, 0.6, n_points // 4)
62
-
63
- cluster4_x = np.random.normal(-1, 0.9, n_points // 4)
64
- cluster4_y = np.random.normal(4, 0.9, n_points // 4)
65
-
66
- x = list(cluster1_x) + list(cluster2_x) + list(cluster3_x) + list(cluster4_x)
67
- y = list(cluster1_y) + list(cluster2_y) + list(cluster3_y) + list(cluster4_y)
68
- labels = [f"Mol_{i}" for i in range(n_points)]
69
-
70
- scatter_chart(x, y, labels, title=f"{viz_type} Projection - {dataset}", height=450)
71
-
72
- with col_details:
73
- section_header("Statistics", "📊")
74
-
75
- metric_card("12,450", "Total Molecules", "🧪", color=COLORS.primary)
76
- spacer("0.75rem")
77
- metric_card("4", "Clusters Found", "🎯", color=COLORS.cyan)
78
- spacer("0.75rem")
79
- metric_card("0.89", "Silhouette Score", "📈", color=COLORS.emerald)
80
- spacer("0.75rem")
81
- metric_card("85%", "Coverage", "✓", color=COLORS.amber)
82
-
83
- spacer("2rem")
84
- divider()
85
- spacer("2rem")
86
-
87
- # Similarity Heatmap
88
- section_header("Similarity Matrix", "🔥")
89
-
90
- # Sample similarity matrix
91
- np.random.seed(123)
92
- labels_short = ["Cluster A", "Cluster B", "Cluster C", "Cluster D", "Cluster E"]
93
- similarity = np.random.uniform(0.3, 1.0, (5, 5))
94
- similarity = (similarity + similarity.T) / 2 # Make symmetric
95
- np.fill_diagonal(similarity, 1.0)
96
-
97
- heatmap(
98
- similarity.tolist(),
99
- labels_short,
100
- labels_short,
101
- title="Inter-cluster Similarity",
102
- height=350
103
- )
104
-
105
- spacer("2rem")
106
-
107
- # Export options
108
- st.markdown(f"""
109
- <div class="card">
110
- <div style="display: flex; justify-content: space-between; align-items: center;">
111
- <div>
112
- <div style="font-weight: 600; color: {COLORS.text_primary};">Export Data</div>
113
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted}; margin-top: 0.25rem;">
114
- Download embeddings, clusters, or full dataset
115
- </div>
116
- </div>
117
- </div>
118
- </div>
119
- """, unsafe_allow_html=True)
120
-
121
- exp_cols = st.columns(3)
122
- with exp_cols[0]:
123
- st.button("📥 Embeddings (CSV)", use_container_width=True)
124
- with exp_cols[1]:
125
- st.button("📥 Clusters (JSON)", use_container_width=True)
126
- with exp_cols[2]:
127
- st.button("📥 Full Dataset", use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/pages/home.py DELETED
@@ -1,213 +0,0 @@
1
- """
2
- BioFlow - Home Page
3
- ====================
4
- Clean dashboard with key metrics and quick actions.
5
- """
6
-
7
- import streamlit as st
8
- import sys
9
- import os
10
-
11
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
12
-
13
- from bioflow.ui.components import (
14
- section_header, divider, spacer,
15
- metric_card, pipeline_progress, bar_chart
16
- )
17
- from bioflow.ui.config import COLORS
18
-
19
-
20
- def render():
21
- """Render home page."""
22
-
23
- # Hero Section (Tailark-inspired)
24
- hero_col, hero_side = st.columns([3, 1.4])
25
-
26
- with hero_col:
27
- st.markdown(f"""
28
- <div class="hero">
29
- <div class="hero-badge">New • BioFlow 2.0</div>
30
- <div class="hero-title">AI-Powered Drug Discovery</div>
31
- <div class="hero-subtitle">
32
- Run discovery pipelines, predict binding, and surface evidence in one streamlined workspace.
33
- </div>
34
- <div class="hero-actions">
35
- <span class="badge badge-primary">Model-aware search</span>
36
- <span class="badge badge-success">Evidence-linked</span>
37
- <span class="badge badge-warning">Fast iteration</span>
38
- </div>
39
- </div>
40
- """, unsafe_allow_html=True)
41
-
42
- spacer("0.75rem")
43
- btn1, btn2 = st.columns(2)
44
- with btn1:
45
- if st.button("Start Discovery", type="primary", use_container_width=True):
46
- st.session_state.current_page = "discovery"
47
- st.rerun()
48
- with btn2:
49
- if st.button("Explore Data", use_container_width=True):
50
- st.session_state.current_page = "explorer"
51
- st.rerun()
52
-
53
- with hero_side:
54
- st.markdown(f"""
55
- <div class="hero-card">
56
- <div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: {COLORS.text_muted};">
57
- Today
58
- </div>
59
- <div style="font-size: 1.75rem; font-weight: 700; color: {COLORS.text_primary}; margin-top: 0.5rem;">
60
- 156 Discoveries
61
- </div>
62
- <div style="font-size: 0.875rem; color: {COLORS.text_muted}; margin-top: 0.5rem;">
63
- +12% vs last week
64
- </div>
65
- <div class="divider" style="margin: 1rem 0;"></div>
66
- <div style="display: flex; flex-direction: column; gap: 0.5rem;">
67
- <span class="badge badge-primary">Discovery</span>
68
- <span class="badge badge-success">Prediction</span>
69
- <span class="badge badge-warning">Evidence</span>
70
- </div>
71
- </div>
72
- """, unsafe_allow_html=True)
73
-
74
- # Metrics Row
75
- cols = st.columns(4)
76
-
77
- with cols[0]:
78
- metric_card("12.5M", "Molecules", "🧪", "+2.3%", "up", COLORS.primary)
79
- with cols[1]:
80
- metric_card("847K", "Proteins", "🧬", "+1.8%", "up", COLORS.cyan)
81
- with cols[2]:
82
- metric_card("1.2M", "Papers", "📚", "+5.2%", "up", COLORS.emerald)
83
- with cols[3]:
84
- metric_card("156", "Discoveries", "✨", "+12%", "up", COLORS.amber)
85
-
86
- spacer("2rem")
87
-
88
- # Quick Actions
89
- section_header("Quick Actions", "⚡")
90
-
91
- action_cols = st.columns(4)
92
-
93
- with action_cols[0]:
94
- st.markdown(f"""
95
- <div class="quick-action">
96
- <span class="quick-action-icon">🔍</span>
97
- <div class="quick-action-title">New Discovery</div>
98
- <div class="quick-action-desc">Start a pipeline</div>
99
- </div>
100
- """, unsafe_allow_html=True)
101
- if st.button("Start", key="qa_discovery", use_container_width=True):
102
- st.session_state.current_page = "discovery"
103
- st.rerun()
104
-
105
- with action_cols[1]:
106
- st.markdown(f"""
107
- <div class="quick-action">
108
- <span class="quick-action-icon">📊</span>
109
- <div class="quick-action-title">Explore Data</div>
110
- <div class="quick-action-desc">Visualize embeddings</div>
111
- </div>
112
- """, unsafe_allow_html=True)
113
- if st.button("Explore", key="qa_explorer", use_container_width=True):
114
- st.session_state.current_page = "explorer"
115
- st.rerun()
116
-
117
- with action_cols[2]:
118
- st.markdown(f"""
119
- <div class="quick-action">
120
- <span class="quick-action-icon">📁</span>
121
- <div class="quick-action-title">Upload Data</div>
122
- <div class="quick-action-desc">Add molecules</div>
123
- </div>
124
- """, unsafe_allow_html=True)
125
- if st.button("Upload", key="qa_data", use_container_width=True):
126
- st.session_state.current_page = "data"
127
- st.rerun()
128
-
129
- with action_cols[3]:
130
- st.markdown(f"""
131
- <div class="quick-action">
132
- <span class="quick-action-icon">⚙️</span>
133
- <div class="quick-action-title">Settings</div>
134
- <div class="quick-action-desc">Configure models</div>
135
- </div>
136
- """, unsafe_allow_html=True)
137
- if st.button("Configure", key="qa_settings", use_container_width=True):
138
- st.session_state.current_page = "settings"
139
- st.rerun()
140
-
141
- spacer("2rem")
142
- divider()
143
- spacer("2rem")
144
-
145
- # Two Column Layout
146
- col1, col2 = st.columns([3, 2])
147
-
148
- with col1:
149
- section_header("Recent Discoveries", "🎯")
150
-
151
- # Sample results
152
- results = [
153
- {"name": "Aspirin analog", "score": 0.94, "mw": "180.16"},
154
- {"name": "Novel kinase inhibitor", "score": 0.87, "mw": "331.39"},
155
- {"name": "EGFR binder candidate", "score": 0.72, "mw": "311.38"},
156
- ]
157
-
158
- for r in results:
159
- score_color = COLORS.emerald if r["score"] >= 0.8 else (COLORS.amber if r["score"] >= 0.5 else COLORS.rose)
160
- st.markdown(f"""
161
- <div class="result">
162
- <div style="display: flex; justify-content: space-between; align-items: center;">
163
- <div style="font-weight: 600; color: {COLORS.text_primary};">{r["name"]}</div>
164
- <div style="font-size: 1.25rem; font-weight: 700; color: {score_color};">{r["score"]:.0%}</div>
165
- </div>
166
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted}; margin-top: 0.5rem;">
167
- MW: {r["mw"]}
168
- </div>
169
- </div>
170
- """, unsafe_allow_html=True)
171
- spacer("0.75rem")
172
-
173
- with col2:
174
- section_header("Pipeline Activity", "📈")
175
-
176
- bar_chart(
177
- {"Mon": 23, "Tue": 31, "Wed": 28, "Thu": 45, "Fri": 38, "Sat": 12, "Sun": 8},
178
- title="",
179
- height=250
180
- )
181
-
182
- spacer("1rem")
183
- section_header("Active Pipeline", "🔄")
184
-
185
- pipeline_progress([
186
- {"name": "Encode", "status": "done"},
187
- {"name": "Search", "status": "active"},
188
- {"name": "Predict", "status": "pending"},
189
- {"name": "Verify", "status": "pending"},
190
- ])
191
-
192
- spacer("2rem")
193
-
194
- # Tip
195
- st.markdown(f"""
196
- <div style="
197
- background: {COLORS.bg_surface};
198
- border: 1px solid {COLORS.border};
199
- border-radius: 12px;
200
- padding: 1.25rem;
201
- display: flex;
202
- align-items: center;
203
- gap: 1rem;
204
- ">
205
- <span style="font-size: 1.5rem;">💡</span>
206
- <div>
207
- <div style="font-size: 0.9375rem; color: {COLORS.text_primary}; font-weight: 500;">Pro Tip</div>
208
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted};">
209
- Use natural language like "Find molecules similar to aspirin that can cross the blood-brain barrier"
210
- </div>
211
- </div>
212
- </div>
213
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/pages/settings.py DELETED
@@ -1,192 +0,0 @@
1
- """
2
- BioFlow - Settings Page
3
- ========================
4
- Configuration and preferences.
5
- """
6
-
7
- import streamlit as st
8
- import sys
9
- import os
10
-
11
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
12
-
13
- from bioflow.ui.components import page_header, section_header, divider, spacer
14
- from bioflow.ui.config import COLORS
15
-
16
-
17
- def render():
18
- """Render settings page."""
19
-
20
- page_header("Settings", "Configure models, databases, and preferences", "⚙️")
21
-
22
- # Tabs for different settings sections
23
- tabs = st.tabs(["🧠 Models", "🗄️ Database", "🔌 API Keys", "🎨 Appearance"])
24
-
25
- with tabs[0]:
26
- section_header("Model Configuration", "🧠")
27
-
28
- st.markdown(f"""
29
- <div class="card" style="margin-bottom: 1rem;">
30
- <div style="font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 0.5rem;">
31
- Embedding Models
32
- </div>
33
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted};">
34
- Configure models used for molecular and protein embeddings
35
- </div>
36
- </div>
37
- """, unsafe_allow_html=True)
38
-
39
- col1, col2 = st.columns(2)
40
-
41
- with col1:
42
- st.selectbox(
43
- "Molecule Encoder",
44
- ["MolCLR (Recommended)", "ChemBERTa", "GraphMVP", "MolBERT"],
45
- help="Model for generating molecular embeddings"
46
- )
47
-
48
- st.selectbox(
49
- "Protein Encoder",
50
- ["ESM-2 (Recommended)", "ProtTrans", "UniRep", "SeqVec"],
51
- help="Model for generating protein embeddings"
52
- )
53
-
54
- with col2:
55
- st.selectbox(
56
- "Binding Predictor",
57
- ["DrugBAN (Recommended)", "DeepDTA", "GraphDTA", "Custom"],
58
- help="Model for predicting drug-target binding"
59
- )
60
-
61
- st.selectbox(
62
- "Property Predictor",
63
- ["ADMET-AI (Recommended)", "ChemProp", "Custom"],
64
- help="Model for ADMET property prediction"
65
- )
66
-
67
- spacer("1rem")
68
-
69
- st.markdown(f"""
70
- <div class="card" style="margin-bottom: 1rem;">
71
- <div style="font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 0.5rem;">
72
- LLM Settings
73
- </div>
74
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted};">
75
- Configure language models for evidence retrieval and reasoning
76
- </div>
77
- </div>
78
- """, unsafe_allow_html=True)
79
-
80
- col1, col2 = st.columns(2)
81
-
82
- with col1:
83
- st.selectbox(
84
- "LLM Provider",
85
- ["OpenAI", "Anthropic", "Local (Ollama)", "Azure OpenAI"]
86
- )
87
-
88
- with col2:
89
- st.selectbox(
90
- "Model",
91
- ["GPT-4o", "GPT-4-turbo", "Claude 3.5 Sonnet", "Llama 3.1 70B"]
92
- )
93
-
94
- st.slider("Temperature", 0.0, 1.0, 0.7, 0.1)
95
- st.number_input("Max Tokens", 100, 4096, 2048, 100)
96
-
97
- with tabs[1]:
98
- section_header("Database Configuration", "🗄️")
99
-
100
- st.markdown(f"""
101
- <div class="card" style="margin-bottom: 1rem;">
102
- <div style="font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 0.5rem;">
103
- Vector Database
104
- </div>
105
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted};">
106
- Configure the vector store for similarity search
107
- </div>
108
- </div>
109
- """, unsafe_allow_html=True)
110
-
111
- col1, col2 = st.columns(2)
112
-
113
- with col1:
114
- st.selectbox("Vector Store", ["Qdrant (Recommended)", "Milvus", "Pinecone", "Weaviate", "ChromaDB"])
115
- st.text_input("Host", value="localhost")
116
-
117
- with col2:
118
- st.number_input("Port", 1, 65535, 6333)
119
- st.text_input("Collection Name", value="bioflow_embeddings")
120
-
121
- spacer("1rem")
122
-
123
- st.markdown(f"""
124
- <div class="card" style="margin-bottom: 1rem;">
125
- <div style="font-weight: 600; color: {COLORS.text_primary}; margin-bottom: 0.5rem;">
126
- Knowledge Sources
127
- </div>
128
- <div style="font-size: 0.8125rem; color: {COLORS.text_muted};">
129
- External databases for evidence retrieval
130
- </div>
131
- </div>
132
- """, unsafe_allow_html=True)
133
-
134
- col1, col2 = st.columns(2)
135
-
136
- with col1:
137
- st.checkbox("PubMed", value=True)
138
- st.checkbox("DrugBank", value=True)
139
- st.checkbox("ChEMBL", value=True)
140
-
141
- with col2:
142
- st.checkbox("UniProt", value=True)
143
- st.checkbox("KEGG", value=False)
144
- st.checkbox("Reactome", value=False)
145
-
146
- with tabs[2]:
147
- section_header("API Keys", "🔌")
148
-
149
- st.warning("⚠️ API keys are stored locally and never sent to external servers.")
150
-
151
- st.text_input("OpenAI API Key", type="password", placeholder="sk-...")
152
- st.text_input("Anthropic API Key", type="password", placeholder="sk-ant-...")
153
- st.text_input("PubMed API Key", type="password", placeholder="Optional - for higher rate limits")
154
- st.text_input("ChEMBL API Key", type="password", placeholder="Optional")
155
-
156
- spacer("1rem")
157
-
158
- if st.button("💾 Save API Keys", type="primary"):
159
- st.success("✓ API keys saved securely")
160
-
161
- with tabs[3]:
162
- section_header("Appearance", "🎨")
163
-
164
- st.selectbox("Theme", ["Dark (Default)", "Light", "System"])
165
- st.selectbox("Accent Color", ["Purple", "Blue", "Green", "Cyan", "Pink"])
166
- st.checkbox("Enable animations", value=True)
167
- st.checkbox("Compact mode", value=False)
168
- st.slider("Font size", 12, 18, 14)
169
-
170
- spacer("2rem")
171
- divider()
172
- spacer("1rem")
173
-
174
- # Save buttons
175
- col1, col2, col3 = st.columns([1, 1, 2])
176
-
177
- with col1:
178
- if st.button("💾 Save Settings", type="primary", use_container_width=True):
179
- st.success("✓ Settings saved successfully!")
180
-
181
- with col2:
182
- if st.button("🔄 Reset to Defaults", use_container_width=True):
183
- st.info("Settings reset to defaults")
184
-
185
- spacer("2rem")
186
-
187
- # Version info
188
- st.markdown(f"""
189
- <div style="text-align: center; padding: 1rem; color: {COLORS.text_muted}; font-size: 0.75rem;">
190
- BioFlow v0.1.0 • Built with OpenBioMed
191
- </div>
192
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bioflow/ui/requirements.txt DELETED
@@ -1,31 +0,0 @@
1
- # BioFlow UI Dependencies
2
- # =======================
3
-
4
- # Core Streamlit
5
- streamlit>=1.29.0
6
-
7
- # Visualization
8
- plotly>=5.18.0
9
- altair>=5.2.0
10
-
11
- # Data handling
12
- pandas>=2.0.0
13
- numpy>=1.24.0
14
-
15
- # Molecular visualization (optional)
16
- rdkit>=2023.9.1
17
- py3Dmol>=2.0.0
18
-
19
- # Machine Learning (optional, for real encoders)
20
- # torch>=2.0.0
21
- # transformers>=4.35.0
22
-
23
- # Vector database
24
- qdrant-client>=1.7.0
25
-
26
- # Image processing
27
- Pillow>=10.0.0
28
-
29
- # Utilities
30
- python-dotenv>=1.0.0
31
- pyyaml>=6.0.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/BIOFLOW_OBM_REPORT.md CHANGED
@@ -4,6 +4,9 @@
4
 
5
  Ce document présente l'intégration complète de **BioFlow** avec **OpenBioMed (OBM)** et **Qdrant** pour créer un système d'intelligence biologique multimodale. L'architecture permet d'unifier textes scientifiques, molécules (SMILES) et protéines dans un espace vectoriel commun, facilitant la découverte cross-modale et la conception de médicaments assistée par IA.
6
 
 
 
 
7
  ---
8
 
9
  ## 📋 Table des matières
@@ -24,7 +27,7 @@ Ce document présente l'intégration complète de **BioFlow** avec **OpenBioMed
24
  ```
25
  ┌─────────────────────────────────────────────────────────────────┐
26
  │ BioFlow Explorer │
27
- │ (Interface Streamlit)
28
  └─────────────────────────────────┬───────────────────────────────┘
29
 
30
  ┌─────────────────────────────────▼───────────────────────────────┐
@@ -140,16 +143,9 @@ Outils de visualisation pour exploration.
140
  - **MoleculeVisualizer** : SVG, grilles de molécules (via RDKit)
141
  - **ResultsVisualizer** : Dashboard, graphiques de scores
142
 
143
- ### 5. Application Streamlit (`bioflow/app.py`)
144
-
145
- Interface interactive complète avec :
146
 
147
- - 🔍 **Search & Explore** : Recherche multimodale
148
- - 📥 **Data Ingestion** : Upload simple/batch
149
- - 🧪 **Cross-Modal Analysis** : Comparaison inter-modalités
150
- - 📊 **Visualization** : Plots embeddings et molécules
151
- - 🔬 **Pipeline Demo** : Workflow de découverte complet
152
- - 📚 **Documentation** : Guide intégré
153
 
154
  ---
155
 
@@ -192,7 +188,7 @@ protein → protein: Protéines homologues
192
  ```bash
193
  # Dépendances principales
194
  pip install -r requirements.txt
195
- pip install qdrant-client streamlit plotly scikit-learn
196
 
197
  # Optionnel pour visualisation moléculaire
198
  pip install rdkit
@@ -202,7 +198,9 @@ pip install rdkit
202
 
203
  ```bash
204
  cd OpenBioMed
205
- streamlit run bioflow/app.py
 
 
206
  ```
207
 
208
  ### Utilisation Programmatique
@@ -350,7 +348,7 @@ if not validation.content["passed"]:
350
  | Mémoire vectorielle centrale | `QdrantManager` avec collection partagée |
351
  | Encodeur multimodal | `OBMWrapper` (BioMedGPT) |
352
  | Nœuds-agents | Classes `*Agent` dans `pipeline.py` |
353
- | Workflow visuel | `BioFlowPipeline` + Streamlit UI |
354
  | Evidence linking | Payload avec `source`, `tags`, scores |
355
 
356
  ### Points d'extension
@@ -385,7 +383,7 @@ pipeline:
385
  - [x] OBM Wrapper avec encodage multimodal
386
  - [x] Intégration Qdrant
387
  - [x] Agents de base (Miner, Validator, Ranker)
388
- - [x] Interface Streamlit
389
  - [x] Mode Mock pour développement
390
 
391
  ### Phase 2 (Court terme)
 
4
 
5
  Ce document présente l'intégration complète de **BioFlow** avec **OpenBioMed (OBM)** et **Qdrant** pour créer un système d'intelligence biologique multimodale. L'architecture permet d'unifier textes scientifiques, molécules (SMILES) et protéines dans un espace vectoriel commun, facilitant la découverte cross-modale et la conception de médicaments assistée par IA.
6
 
7
+ > **Note (27/01/2026)**: L'interface Streamlit historique a été retirée du runtime.
8
+ > L'UI officielle est **Next.js** (dossier `ui/`) et le backend est **FastAPI** (port 8000).
9
+
10
  ---
11
 
12
  ## 📋 Table des matières
 
27
  ```
28
  ┌─────────────────────────────────────────────────────────────────┐
29
  │ BioFlow Explorer │
30
+ │ (Interface Next.js)
31
  └─────────────────────────────────┬───────────────────────────────┘
32
 
33
  ┌─────────────────────────────────▼───────────────────────────────┐
 
143
  - **MoleculeVisualizer** : SVG, grilles de molécules (via RDKit)
144
  - **ResultsVisualizer** : Dashboard, graphiques de scores
145
 
146
+ ### 5. Application Web (Next.js)
 
 
147
 
148
+ L'interface officielle est la **Next.js UI** dans `ui/` (aucun runtime Streamlit).
 
 
 
 
 
149
 
150
  ---
151
 
 
188
  ```bash
189
  # Dépendances principales
190
  pip install -r requirements.txt
191
+ pip install qdrant-client plotly scikit-learn
192
 
193
  # Optionnel pour visualisation moléculaire
194
  pip install rdkit
 
198
 
199
  ```bash
200
  cd OpenBioMed
201
+ # UI (Next.js)
202
+ cd ui
203
+ pnpm dev
204
  ```
205
 
206
  ### Utilisation Programmatique
 
348
  | Mémoire vectorielle centrale | `QdrantManager` avec collection partagée |
349
  | Encodeur multimodal | `OBMWrapper` (BioMedGPT) |
350
  | Nœuds-agents | Classes `*Agent` dans `pipeline.py` |
351
+ | Workflow visuel | **Next.js UI** (`ui/`) + API FastAPI |
352
  | Evidence linking | Payload avec `source`, `tags`, scores |
353
 
354
  ### Points d'extension
 
383
  - [x] OBM Wrapper avec encodage multimodal
384
  - [x] Intégration Qdrant
385
  - [x] Agents de base (Miner, Validator, Ranker)
386
+ - [x] Interface Next.js (UI officielle)
387
  - [x] Mode Mock pour développement
388
 
389
  ### Phase 2 (Court terme)
docs/COMPLIANCE_REPORT.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioFlow Compliance Report (Phase 1)
2
+
3
+ **Date:** 2026-01-27
4
+ **Scope:** Open-source compliance + forbidden runtime dependencies
5
+
6
+ ## Summary
7
+ - **Streamlit UI removed** from runtime and repository path.
8
+ - **OpenAI / Azure OpenAI / Anthropic UI references removed**.
9
+ - **Runtime stack** confirmed: FastAPI + Next.js + Qdrant only.
10
+
11
+ ## Removed / Deprecated
12
+ - `bioflow/app.py` (legacy Streamlit app) **deleted**
13
+ - `bioflow/ui/*` (Streamlit UI package) **deleted**
14
+ - Streamlit dependency **removed** from runtime requirements
15
+ - UI settings no longer expose proprietary LLM providers
16
+
17
+ ## Allowed / Kept
18
+ - **OBM (OpenBioMed)** for embeddings only
19
+ - **DeepPurpose** for DTI (open-source)
20
+ - **Qdrant** as primary vector database
21
+
22
+ ## Dependencies (Runtime)
23
+ From `requirements.txt` (open-source only):
24
+ - `fastapi`, `uvicorn`
25
+ - `qdrant-client`
26
+ - `torch`, `transformers`, `rdkit`, `numpy`, `scikit-learn`
27
+ - `requests`, `pandas`, `dotenv`
28
+
29
+ ## Remaining Risks / Follow-ups
30
+ - **Legacy references in docs** should avoid implying Streamlit runtime.
31
+ - Ensure **no proprietary endpoints** are configured in deployment.
32
+
33
+ ## Evidence
34
+ - Streamlit files removed: `bioflow/app.py`, `bioflow/ui/*`
35
+ - UI settings updated: `ui/app/dashboard/settings/page.tsx`
36
+
docs/FRONTEND_FALLBACKS.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend Fallback Behavior (Phase 5)
2
+
3
+ ## Overview
4
+ The UI uses Next.js `/api/*` route handlers as a proxy layer to the FastAPI backend.
5
+ If the backend is unavailable, these routes return **safe defaults** so the UI remains usable.
6
+
7
+ ## Proxy Routes (Next.js)
8
+ All routes forward to `API_CONFIG.baseUrl` (`NEXT_PUBLIC_API_URL`, default `http://localhost:8000`).
9
+
10
+ ### Search
11
+ - `POST /api/search`
12
+ - `POST /api/search/hybrid`
13
+ - Fallback: empty results with metadata stubs
14
+
15
+ ### Agents
16
+ - `POST /api/agents/generate`
17
+ - `POST /api/agents/validate`
18
+ - `POST /api/agents/rank`
19
+ - `POST /api/agents/workflow`
20
+ - Fallback: empty payloads with `mock: true` flags (where applicable)
21
+
22
+ ### Explorer
23
+ - `GET /api/explorer/embeddings`
24
+ - Fallback: `503` + empty points
25
+
26
+ ### Ingestion
27
+ - `POST /api/ingest/pubmed`
28
+ - `POST /api/ingest/uniprot`
29
+ - `POST /api/ingest/chembl`
30
+ - `POST /api/ingest/all`
31
+ - `GET /api/ingest/jobs/{job_id}`
32
+ - Fallback: `503` + error message
33
+
34
+ ## UI Empty‑State Handling
35
+ - Visualization page shows a **“No points to display”** message until a search runs.
36
+ - Workflow page shows **“Run workflow to see results”** until execution completes.
37
+
38
+ ## Recommendation
39
+ For demos, keep the FastAPI backend running to ensure real data/embeddings are shown.
40
+
docs/INGESTION_GUIDE.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioFlow Ingestion Guide (Phase 3)
2
+
3
+ This guide explains how to ingest data from **PubMed**, **UniProt**, and **ChEMBL** into Qdrant.
4
+
5
+ ## 1) FastAPI Endpoints (Recommended)
6
+
7
+ ### PubMed
8
+ `POST /api/ingest/pubmed`
9
+ ```json
10
+ {
11
+ "query": "EGFR lung cancer",
12
+ "limit": 100,
13
+ "batch_size": 50,
14
+ "rate_limit": 0.4,
15
+ "collection": "bioflow_memory",
16
+ "email": "you@example.com",
17
+ "api_key": "NCBI_API_KEY",
18
+ "sync": false
19
+ }
20
+ ```
21
+
22
+ ### UniProt
23
+ `POST /api/ingest/uniprot`
24
+ ```json
25
+ {
26
+ "query": "EGFR AND organism_id:9606",
27
+ "limit": 50,
28
+ "batch_size": 50,
29
+ "rate_limit": 0.2,
30
+ "collection": "bioflow_memory",
31
+ "sync": false
32
+ }
33
+ ```
34
+
35
+ ### ChEMBL
36
+ `POST /api/ingest/chembl`
37
+ ```json
38
+ {
39
+ "query": "EGFR",
40
+ "limit": 30,
41
+ "batch_size": 50,
42
+ "rate_limit": 0.3,
43
+ "collection": "bioflow_memory",
44
+ "search_mode": "target",
45
+ "sync": false
46
+ }
47
+ ```
48
+
49
+ ### All Sources
50
+ `POST /api/ingest/all`
51
+ ```json
52
+ {
53
+ "query": "EGFR lung cancer",
54
+ "pubmed_limit": 100,
55
+ "uniprot_limit": 50,
56
+ "chembl_limit": 30,
57
+ "batch_size": 50,
58
+ "rate_limit": 0.3,
59
+ "collection": "bioflow_memory",
60
+ "sync": false
61
+ }
62
+ ```
63
+
64
+ ### Job Status
65
+ `GET /api/ingest/jobs/{job_id}`
66
+
67
+ ## 2) Next.js Proxy Routes (Optional)
68
+ If you want to call the backend through Next.js:
69
+ ```
70
+ /api/ingest/pubmed
71
+ /api/ingest/uniprot
72
+ /api/ingest/chembl
73
+ /api/ingest/all
74
+ /api/ingest/jobs/{job_id}
75
+ ```
76
+
77
+ ## 3) CLI Ingestion
78
+ ```
79
+ python -m bioflow.ingestion.ingest_all --query "EGFR lung cancer" --limit 100
80
+ ```
81
+
82
+ ## 4) Environment Variables
83
+ - `INGEST_BATCH_SIZE`
84
+ - `PUBMED_RATE_LIMIT`
85
+ - `UNIPROT_RATE_LIMIT`
86
+ - `CHEMBL_RATE_LIMIT`
87
+ - `NCBI_EMAIL`
88
+ - `NCBI_API_KEY`
89
+ - `CHEMBL_SEARCH_MODE`
90
+
91
+ ## 5) Recommended Minimums
92
+ - PubMed: 100 records
93
+ - UniProt: 50 records
94
+ - ChEMBL: 30 records
95
+
docs/METADATA_SCHEMA.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioFlow Metadata Schema (Phase 3)
2
+
3
+ All ingested items are stored in Qdrant with a **payload** that includes core provenance fields plus source‑specific metadata.
4
+
5
+ ## Core Fields (all modalities)
6
+
7
+ | Field | Type | Description |
8
+ |------|------|-------------|
9
+ | `source` | string | Source name (`pubmed`, `uniprot`, `chembl`) |
10
+ | `source_id` | string | Source identifier (e.g., `pubmed:12345`) |
11
+ | `indexed_at` | string | ISO timestamp when ingested |
12
+ | `content` | string | Stored raw content (text, SMILES, or sequence) |
13
+ | `modality` | string | `text`, `molecule`, or `protein` |
14
+
15
+ ## PubMed (text)
16
+
17
+ | Field | Type | Description |
18
+ |------|------|-------------|
19
+ | `pmid` | string | PubMed ID |
20
+ | `title` | string | Article title |
21
+ | `authors` | list[string] | Authors |
22
+ | `journal` | string | Journal name |
23
+ | `pub_date` | string | Publication date |
24
+ | `year` | number | Publication year |
25
+ | `mesh_terms` | list[string] | MeSH terms |
26
+ | `url` | string | PubMed URL |
27
+
28
+ ## UniProt (protein)
29
+
30
+ | Field | Type | Description |
31
+ |------|------|-------------|
32
+ | `accession` | string | UniProt accession |
33
+ | `entry_name` | string | UniProt entry name |
34
+ | `protein_name` | string | Protein name |
35
+ | `gene_names` | list[string] | Gene names |
36
+ | `organism` | string | Scientific name |
37
+ | `organism_id` | string | Taxon ID |
38
+ | `function` | string | Function text (truncated) |
39
+ | `sequence_length` | number | Sequence length |
40
+ | `pdb_ids` | list[string] | PDB references |
41
+ | `url` | string | UniProt URL |
42
+
43
+ ## ChEMBL (molecule)
44
+
45
+ | Field | Type | Description |
46
+ |------|------|-------------|
47
+ | `chembl_id` | string | ChEMBL molecule ID |
48
+ | `name` | string | Preferred name |
49
+ | `synonyms` | list[string] | Synonyms (limited) |
50
+ | `smiles` | string | Canonical SMILES |
51
+ | `inchi_key` | string | InChIKey |
52
+ | `molecular_weight` | number | Full molecular weight |
53
+ | `alogp` | number | ALogP |
54
+ | `hba` | number | H‑bond acceptors |
55
+ | `hbd` | number | H‑bond donors |
56
+ | `psa` | number | Polar surface area |
57
+ | `ro5_violations` | number | Rule‑of‑5 violations |
58
+ | `target_chembl_id` | string | Target ID (if available) |
59
+ | `activity_type` | string | Activity type (e.g., IC50) |
60
+ | `activity_value` | number | Activity value |
61
+ | `activity_units` | string | Activity units |
62
+ | `url` | string | ChEMBL URL |
docs/OBSERVABILITY.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Observability (Phase 6)
2
+
3
+ ## Structured Logs
4
+ FastAPI emits JSON logs for key actions:
5
+ - `search` / `search_error`
6
+ - `ingest_single` / `ingest_single_error`
7
+ - `workflow` / `workflow_error`
8
+
9
+ Each log includes:
10
+ - `event`
11
+ - `request_id`
12
+ - `timestamp`
13
+ - relevant fields (query, top_k, duration_ms, etc.)
14
+
15
+ ## Health Metrics Endpoint
16
+ `GET /api/health/metrics`
17
+
18
+ Returns:
19
+ ```json
20
+ {
21
+ "status": "ok",
22
+ "timestamp": "...",
23
+ "qdrant": {
24
+ "available": true,
25
+ "collections": ["molecules", "proteins"],
26
+ "stats": { "molecules": { "points_count": 1234 } }
27
+ },
28
+ "models": {
29
+ "available": true,
30
+ "device": "cuda",
31
+ "obm_loaded": true
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## CI-Style Test Runner
37
+ `python scripts/run_tests.py`
38
+
39
+ Runs:
40
+ - `test_search_api.py`
41
+ - `test_agent_api.py`
42
+ - `test_search_filters.py`
43
+ - `test_phase4_ui.py`
44
+ - `test_ingestion_api.py`
45
+
docs/ROADMAP.md CHANGED
@@ -98,75 +98,21 @@ The team works on their respective modules using the core interfaces.
98
  ## 📊 Phase 4: UI/UX & Deployment ✅ COMPLETE
99
  **Goal:** Build an intuitive, modern interface for the BioFlow platform.
100
 
101
- - [x] **Theme & Styling** (`bioflow/ui/config.py`):
102
- - Glassmorphism design system with dark theme
103
- - Custom CSS with animations, cards, badges
104
- - Responsive layout with Inter font family
105
- - Color palette: Indigo primary, Emerald success, Cyan accent
106
-
107
- - [x] **Reusable Components** (`bioflow/ui/components.py`):
108
- - `hero_section`: Landing hero with stats
109
- - `metric_card`: Animated metric displays
110
- - `glass_card`, `feature_cards`: Content cards
111
- - `pipeline_flow`: Visual pipeline status
112
- - `binding_affinity_chart`, `scatter_embedding`, `similarity_heatmap`: Charts
113
- - `molecule_viewer_2d`: RDKit molecule rendering
114
- - `evidence_card`: Traceability links
115
- - `chat_message`, `chat_container`: AI chat interface
116
- - `step_progress`, `notification`: UX helpers
117
-
118
- - [x] **Dashboard Home** (`bioflow/ui/pages/home.py`):
119
- - Hero section with platform branding
120
- - Key metrics (molecules, proteins, literature, predictions)
121
- - Quick action cards (Discovery, Explorer, Upload)
122
- - Feature highlights grid
123
- - Recent discoveries chart
124
- - Activity timeline
125
- - Active pipeline visualization
126
-
127
- - [x] **Discovery Page** (`bioflow/ui/pages/discovery.py`):
128
- - Query input (text, SMILES, FASTA)
129
- - Target protein selection with common targets
130
- - Real-time pipeline progress visualization
131
- - Results with binding affinity chart
132
- - Top hits with molecule viewer
133
- - Evidence linking to PubMed/ChEMBL/PubChem
134
- - Export options (CSV, SMILES, Report)
135
-
136
- - [x] **Explorer Page** (`bioflow/ui/pages/explorer.py`):
137
- - 2D/3D embedding visualization
138
- - Dimensionality reduction (t-SNE, PCA, UMAP)
139
- - Modality filtering
140
- - Cross-modal similarity heatmap
141
- - Nearest neighbor search
142
- - Cluster analysis with K-Means/DBSCAN
143
-
144
- - [x] **Data Management Page** (`bioflow/ui/pages/data.py`):
145
- - Collection overview with metrics
146
- - File upload (CSV, JSON, SMILES, FASTA)
147
- - Data preview with molecule rendering
148
- - Batch processing with progress
149
- - Collection browsing and search
150
- - Scheduled task management
151
-
152
- - [x] **Settings Page** (`bioflow/ui/pages/settings.py`):
153
- - Qdrant connection configuration
154
- - Model selection (PubMedBERT, ESM-2, ChemBERTa)
155
- - Predictor configuration (DeepPurpose)
156
- - Theme and appearance settings
157
- - System status monitoring
158
- - Resource usage (Memory, GPU, Storage)
159
-
160
- - [x] **Main App** (`bioflow/ui/app.py`):
161
- - Navigation sidebar with routing
162
- - User profile display
163
- - Quick stats in sidebar
164
-
165
- **Launch:** `python launch_ui.py` or `streamlit run bioflow/ui/app.py`
166
 
167
  ---
168
 
169
  ## 🚀 Phase 5: Open-Source Alignment
170
- - **Laila Connector**: Allow Laila to query the Qdrant memory.
171
- - **InstaNovo+ Specs**: Add support for peptide sequencing integration.
172
- - **Controlled Generation**: Pilot generation via ProtBFN/AbBFN2.
 
98
  ## 📊 Phase 4: UI/UX & Deployment ✅ COMPLETE
99
  **Goal:** Build an intuitive, modern interface for the BioFlow platform.
100
 
101
+ - [x] **Next.js Frontend** (`ui/`):
102
+ - Next.js 16 app router + Tailwind + shadcn/ui
103
+ - Dashboard pages: Discovery, 3D Visualization, Workflow Builder
104
+ - `/app/api/*` proxy routes to the FastAPI backend
105
+ - Optional mock fallbacks for molecules/proteins list routes
106
+
107
+ **Launch:**
108
+ - Full stack (Windows): `launch_bioflow_full.bat`
109
+ - Manual:
110
+ - Backend: `python -m uvicorn bioflow.api.server:app --host 0.0.0.0 --port 8000`
111
+ - UI: `cd ui && pnpm dev`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  ---
114
 
115
  ## 🚀 Phase 5: Open-Source Alignment
116
+ - **Strict Open-Source Compliance**: remove proprietary integrations and keep only OSS models/tools.
117
+ - **Open Protein/Peptide Options**: integrate open models (e.g., ESM-2 / ProGen2) behind `BioGenerator`.
118
+ - **Open Retrieval + Evidence**: improve evidence traceability (PubMed/UniProt/ChEMBL) and evaluation.
launch_bioflow.bat CHANGED
@@ -6,10 +6,18 @@ echo ║ 🧬 BioFlow - AI-Powered Drug Discovery Platform ║
6
  echo ║ ║
7
  echo ╚══════════════════════════════════════════════════════════╝
8
  echo.
9
- echo Starting BioFlow UI...
10
  echo.
11
 
12
  cd /d "%~dp0"
13
- python -m streamlit run bioflow/ui/app.py --server.port 8501
 
 
 
 
 
 
 
 
14
 
15
  pause
 
6
  echo ║ ║
7
  echo ╚══════════════════════════════════════════════════════════╝
8
  echo.
9
+ echo Starting BioFlow UI (Next.js)...
10
  echo.
11
 
12
  cd /d "%~dp0"
13
+ if not exist "ui\package.json" (
14
+ echo ❌ Error: Next.js UI not found at .\ui
15
+ echo Run `launch_bioflow_full.bat` from the repo root.
16
+ pause
17
+ exit /b 1
18
+ )
19
+
20
+ cd /d "%~dp0\ui"
21
+ pnpm dev
22
 
23
  pause
launch_bioflow_full.bat CHANGED
@@ -22,7 +22,7 @@ start "BioFlow API" cmd /k "cd /d %~dp0 && python -m uvicorn bioflow.api.server:
22
 
23
  echo [2/2] Starting Next.js Frontend on port 3000...
24
  timeout /t 3 /nobreak > nul
25
- start "BioFlow UI" cmd /k "cd /d %~dp0\lacoste001\ui && pnpm dev"
26
 
27
  echo.
28
  echo ============================================
 
22
 
23
  echo [2/2] Starting Next.js Frontend on port 3000...
24
  timeout /t 3 /nobreak > nul
25
+ start "BioFlow UI" cmd /k "cd /d %~dp0\ui && pnpm dev"
26
 
27
  echo.
28
  echo ============================================
launch_ui.py CHANGED
@@ -2,11 +2,11 @@
2
  BioFlow UI Launch Script
3
  =========================
4
 
5
- Quick launcher for the BioFlow Streamlit application.
6
 
7
  Usage:
8
  python launch_ui.py
9
- python launch_ui.py --port 8502
10
  python launch_ui.py --debug
11
  """
12
 
@@ -18,16 +18,15 @@ from pathlib import Path
18
 
19
  def main():
20
  """Launch the BioFlow UI."""
21
- # Get the app path
22
  script_dir = Path(__file__).parent
23
- app_path = script_dir / "bioflow" / "ui" / "app.py"
24
-
25
- if not app_path.exists():
26
- print(f"❌ Error: App not found at {app_path}")
27
  sys.exit(1)
28
 
29
  # Parse arguments
30
- port = 8501
31
  debug = False
32
 
33
  for i, arg in enumerate(sys.argv[1:]):
@@ -36,17 +35,10 @@ def main():
36
  elif arg == "--debug":
37
  debug = True
38
 
39
- # Build command
40
- cmd = [
41
- sys.executable, "-m", "streamlit", "run",
42
- str(app_path),
43
- "--server.port", str(port),
44
- "--server.headless", "false",
45
- "--browser.gatherUsageStats", "false",
46
- ]
47
-
48
  if debug:
49
- cmd.extend(["--logger.level", "debug"])
50
 
51
  print(f"""
52
  ╔══════════════════════════════════════════════════════════╗
@@ -59,7 +51,7 @@ def main():
59
  """)
60
 
61
  try:
62
- subprocess.run(cmd, cwd=str(script_dir))
63
  except KeyboardInterrupt:
64
  print("\n\n👋 BioFlow server stopped.")
65
  except Exception as e:
 
2
  BioFlow UI Launch Script
3
  =========================
4
 
5
+ Quick launcher for the BioFlow Next.js application.
6
 
7
  Usage:
8
  python launch_ui.py
9
+ python launch_ui.py --port 3001
10
  python launch_ui.py --debug
11
  """
12
 
 
18
 
19
  def main():
20
  """Launch the BioFlow UI."""
 
21
  script_dir = Path(__file__).parent
22
+ ui_dir = script_dir / "ui"
23
+
24
+ if not (ui_dir / "package.json").exists():
25
+ print(f"❌ Error: Next.js UI not found at {ui_dir}")
26
  sys.exit(1)
27
 
28
  # Parse arguments
29
+ port = 3000
30
  debug = False
31
 
32
  for i, arg in enumerate(sys.argv[1:]):
 
35
  elif arg == "--debug":
36
  debug = True
37
 
38
+ env = os.environ.copy()
39
+ env["PORT"] = str(port)
 
 
 
 
 
 
 
40
  if debug:
41
+ env["NODE_OPTIONS"] = env.get("NODE_OPTIONS", "") + " --trace-warnings"
42
 
43
  print(f"""
44
  ╔══════════════════════════════════════════════════════════╗
 
51
  """)
52
 
53
  try:
54
+ subprocess.run(["pnpm", "dev"], cwd=str(ui_dir), env=env, check=False)
55
  except KeyboardInterrupt:
56
  print("\n\n👋 BioFlow server stopped.")
57
  except Exception as e:
open_biomed/__init__.py CHANGED
@@ -1,6 +1,17 @@
1
- from open_biomed.utils import *
2
- from open_biomed.models import *
3
- from open_biomed.datasets import *
4
- from open_biomed.tasks import *
5
- from open_biomed.core import *
6
- from open_biomed.scripts import *
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenBioMed package (vendored)
3
+ =============================
4
+
5
+ This repository vendors an upstream OpenBioMed codebase. For BioFlow, OpenBioMed
6
+ is treated as an optional dependency: BioFlow must remain importable even when
7
+ some heavy optional dependencies (e.g., `scanpy`) are not installed.
8
+
9
+ To avoid import-time failures, this package intentionally does not `import *`
10
+ from all submodules at import time. Import the specific subpackages you need:
11
+
12
+ - `open_biomed.core.*`
13
+ - `open_biomed.data.*`
14
+ - `open_biomed.models.*`
15
+ """
16
+
17
+ __all__ = []
open_biomed/core/llm_request.py CHANGED
@@ -9,8 +9,7 @@ import shutil
9
  from datetime import datetime
10
  from typing import List, Optional, TypedDict, AsyncGenerator, Tuple
11
  from typing_extensions import Self
12
- from openai import OpenAI, Stream
13
- from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, ChoiceDelta
14
 
15
  from open_biomed.data import Text
16
  from open_biomed.utils.config import Config
@@ -77,7 +76,7 @@ class LLM_Local(LLM):
77
  except:
78
  raise ValueError("Only support BioMedGPTR1 and BioMedGPT for now.")
79
 
80
- self._init_client(api_infos)
81
 
82
  def _init_client(self, model_name_or_path: str, device: Optional[str]=None) -> Self:
83
  self.client = self.client_model.from_pretrained(model_name_or_path=model_name_or_path, device=device)
@@ -111,11 +110,15 @@ class LLM_API(LLM):
111
  self.think_start, self.think_end = "<think>", "</think>"
112
 
113
  def _init_client(self, api_infos: dict) -> Self:
114
-
115
- self.client = OpenAI(
116
- api_key=api_infos['api_key'],
117
- base_url=api_infos['api_url']
118
- )
 
 
 
 
119
 
120
 
121
  def _update_query(self, query: str, context: ContextDict = {"ref_text": "", "others": dict()}) -> str:
@@ -131,46 +134,54 @@ class LLM_API(LLM):
131
 
132
  return messages
133
 
134
- async def generate_stream(self, query: str, context: ContextDict = {"ref_text": "", "others": dict()}, is_debug=False):
135
-
136
- messages = self._get_input(query=query, context=context, is_debug=is_debug)
137
-
138
- stream: Stream[ChatCompletionChunk] = self.client.chat.completions.create(
139
- model=self.model_name,
140
- messages=messages,
141
- stream=True,
142
- )
143
-
144
- is_think = False
145
- for chunk in stream:
146
- delta: ChoiceDelta = chunk.choices[0].delta
147
- delta_json = delta.model_dump()
148
- content = delta_json.get("content", "")
149
- if content != "" or content!=None:
150
- if content == self.think_start:
151
- is_think = True
152
- continue
153
- elif content == self.think_end:
154
- is_think = False
155
- continue
156
- elif is_think:
157
- stream_chunk: StreamChunk = {"final_resp": "", "reasoning": content}
158
- else:
159
- stream_chunk: StreamChunk = {"final_resp": content, "reasoning": ""}
160
- yield stream_chunk
161
 
162
  def generate(self, query: str, context: ContextDict = {"ref_text": "", "others": dict()}, is_debug=False):
163
 
164
  messages = self._get_input(query=query, context=context, is_debug=is_debug)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- response = self.client.chat.completions.create(
167
- model=self.model_name,
168
- messages=messages,
169
- stream=False,
170
- temperature=self.temperature
171
- )
172
 
173
- text=response.choices[0].message.content
 
 
 
 
174
  start_index = text.find(self.think_start) + len(self.think_start)
175
  end_index = text.find(self.think_end)
176
  resp_thinking = text[start_index:end_index].strip()
 
9
  from datetime import datetime
10
  from typing import List, Optional, TypedDict, AsyncGenerator, Tuple
11
  from typing_extensions import Self
12
+ import requests
 
13
 
14
  from open_biomed.data import Text
15
  from open_biomed.utils.config import Config
 
76
  except:
77
  raise ValueError("Only support BioMedGPTR1 and BioMedGPT for now.")
78
 
79
+ self._init_client(model_name_or_path=model_name_or_path, device=device)
80
 
81
  def _init_client(self, model_name_or_path: str, device: Optional[str]=None) -> Self:
82
  self.client = self.client_model.from_pretrained(model_name_or_path=model_name_or_path, device=device)
 
110
  self.think_start, self.think_end = "<think>", "</think>"
111
 
112
  def _init_client(self, api_infos: dict) -> Self:
113
+ # NOTE: OpenAI SDK usage is intentionally avoided to keep the project fully open-source.
114
+ # This client speaks a minimal OpenAI-compatible HTTP interface (e.g., local vLLM/Ollama proxy).
115
+ self.api_key = api_infos.get("api_key")
116
+ self.api_url = (api_infos.get("api_url") or "").rstrip("/")
117
+ if not self.api_url:
118
+ raise ValueError("API_URL is required for LLM_API (set in .env)")
119
+ if not api_infos.get("model_name"):
120
+ raise ValueError("MODEL_NAME is required for LLM_API (set in .env)")
121
+ return self
122
 
123
 
124
  def _update_query(self, query: str, context: ContextDict = {"ref_text": "", "others": dict()}) -> str:
 
134
 
135
  return messages
136
 
137
+ async def generate_stream(
138
+ self,
139
+ query: str,
140
+ context: ContextDict = {"ref_text": "", "others": dict()},
141
+ is_debug: bool = False,
142
+ ) -> AsyncGenerator[StreamChunk, None]:
143
+ """
144
+ Best-effort streaming adapter.
145
+
146
+ This implementation does not rely on proprietary SDKs. If you need true token streaming,
147
+ run an OpenAI-compatible server that supports SSE streaming and implement parsing here.
148
+ """
149
+ resp = self.generate(query=query, context=context, is_debug=is_debug)
150
+ yield {"final_resp": resp.get("final_resp", ""), "reasoning": resp.get("reasoning", "")}
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  def generate(self, query: str, context: ContextDict = {"ref_text": "", "others": dict()}, is_debug=False):
153
 
154
  messages = self._get_input(query=query, context=context, is_debug=is_debug)
155
+ # Build OpenAI-compatible endpoint
156
+ base = self.api_url
157
+ if base.endswith("/v1"):
158
+ endpoint = f"{base}/chat/completions"
159
+ elif "/v1/" in base:
160
+ # If user provided a deeper URL, try to append the standard path safely.
161
+ endpoint = f"{base.rstrip('/')}/chat/completions"
162
+ else:
163
+ endpoint = f"{base}/v1/chat/completions"
164
+
165
+ headers = {"Content-Type": "application/json"}
166
+ if self.api_key:
167
+ headers["Authorization"] = f"Bearer {self.api_key}"
168
+
169
+ payload = {
170
+ "model": self.model_name,
171
+ "messages": messages,
172
+ "stream": False,
173
+ "temperature": self.temperature,
174
+ }
175
 
176
+ r = requests.post(endpoint, json=payload, headers=headers, timeout=120)
177
+ r.raise_for_status()
178
+ data = r.json()
 
 
 
179
 
180
+ text = (
181
+ data.get("choices", [{}])[0]
182
+ .get("message", {})
183
+ .get("content", "")
184
+ )
185
  start_index = text.find(self.think_start) + len(self.think_start)
186
  end_index = text.find(self.think_end)
187
  resp_thinking = text[start_index:end_index].strip()
open_biomed/data/__init__.py CHANGED
@@ -1,5 +1,10 @@
1
  from open_biomed.data.molecule import *
2
  from open_biomed.data.protein import *
3
  from open_biomed.data.pocket import *
4
- from open_biomed.data.cell import *
5
- from open_biomed.data.text import *
 
 
 
 
 
 
1
  from open_biomed.data.molecule import *
2
  from open_biomed.data.protein import *
3
  from open_biomed.data.pocket import *
4
+ from open_biomed.data.text import *
5
+
6
+ # Optional modality (heavy dependency).
7
+ try:
8
+ from open_biomed.data.cell import * # type: ignore
9
+ except ImportError:
10
+ pass
open_biomed/data/molecule.py CHANGED
@@ -12,8 +12,17 @@ from rdkit import Chem, DataStructs, RDLogger
12
  RDLogger.DisableLog("rdApp.*")
13
  from rdkit.Chem import AllChem, MACCSkeys, rdMolDescriptors, Descriptors, Lipinski
14
  from rdkit.Chem.AllChem import RWMol
15
- from rdkit.six import iteritems
16
- from rdkit.six.moves import cPickle
 
 
 
 
 
 
 
 
 
17
  import re
18
 
19
  from open_biomed.core.tool import Tool
@@ -458,4 +467,4 @@ class MoleculeSimilarityTool(Tool):
458
 
459
  def run(self, molecule_1: Molecule, molecule_2: Molecule) -> float:
460
  return molecule_fingerprint_similarity(molecule_1, molecule_2, fingerprint_type="morgan")
461
-
 
12
  RDLogger.DisableLog("rdApp.*")
13
  from rdkit.Chem import AllChem, MACCSkeys, rdMolDescriptors, Descriptors, Lipinski
14
  from rdkit.Chem.AllChem import RWMol
15
+ try:
16
+ # Older RDKit vendored `six` utilities here.
17
+ from rdkit.six import iteritems # type: ignore
18
+ except Exception:
19
+ def iteritems(d):
20
+ return d.items()
21
+
22
+ try:
23
+ from rdkit.six.moves import cPickle # type: ignore
24
+ except Exception:
25
+ import pickle as cPickle
26
  import re
27
 
28
  from open_biomed.core.tool import Tool
 
467
 
468
  def run(self, molecule_1: Molecule, molecule_2: Molecule) -> float:
469
  return molecule_fingerprint_similarity(molecule_1, molecule_2, fingerprint_type="morgan")
470
+
qdrant_data/.lock DELETED
@@ -1 +0,0 @@
1
- tmp lock file
 
 
qdrant_data/collection/bioflow_memory/storage.sqlite DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1c3e8b65a92a7fd78e4c3d3cb9dac1ded03b80544437fb331469ba98046d6e8b
3
- size 409600
 
 
 
 
qdrant_data/collection/molecules/storage.sqlite DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:83a947b90cba756851eb0b4cc10fa98f98c58936296716e81bf17a053d2679ed
3
- size 45056
 
 
 
 
qdrant_data/meta.json DELETED
@@ -1 +0,0 @@
1
- {"collections": {"molecules": {"vectors": {"size": 768, "distance": "Cosine", "hnsw_config": null, "quantization_config": null, "on_disk": null, "datatype": null, "multivector_config": null}, "shard_number": null, "sharding_method": null, "replication_factor": null, "write_consistency_factor": null, "on_disk_payload": null, "hnsw_config": null, "wal_config": null, "optimizers_config": null, "quantization_config": null, "sparse_vectors": null, "strict_mode_config": null, "metadata": null}, "bioflow_memory": {"vectors": {"size": 768, "distance": "Cosine", "hnsw_config": null, "quantization_config": null, "on_disk": null, "datatype": null, "multivector_config": null}, "shard_number": null, "sharding_method": null, "replication_factor": null, "write_consistency_factor": null, "on_disk_payload": null, "hnsw_config": null, "wal_config": null, "optimizers_config": null, "quantization_config": null, "sparse_vectors": null, "strict_mode_config": null, "metadata": null}}, "aliases": {}}
 
 
requirements.txt CHANGED
@@ -10,14 +10,13 @@ numpy==1.26.4
10
  absl-py==2.1.0
11
  easydict==1.13
12
  ratelimiter==1.2.0.post0
13
- openai==1.64.0
14
  uvicorn===0.32.1
15
  fastapi===0.115.5
16
  oss2==2.19.1
17
- dotenv==1.0.1
18
  protobuf==5.28.3
19
  scanpy==1.10.3
20
  qdrant-client>=1.7.0
21
- streamlit>=1.28.0
22
  plotly>=5.18.0
23
  scikit-learn>=1.3.0
 
10
  absl-py==2.1.0
11
  easydict==1.13
12
  ratelimiter==1.2.0.post0
13
+ requests>=2.31.0
14
  uvicorn===0.32.1
15
  fastapi===0.115.5
16
  oss2==2.19.1
17
+ python-dotenv>=1.0.1
18
  protobuf==5.28.3
19
  scanpy==1.10.3
20
  qdrant-client>=1.7.0
 
21
  plotly>=5.18.0
22
  scikit-learn>=1.3.0
scripts/benchmark_mmr.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Compare /api/search latency + diversity with and without MMR.
4
+ """
5
+ import os
6
+ import statistics
7
+ import time
8
+ import requests
9
+
10
+ BASE_URL = os.getenv("BIOFLOW_API_URL", "http://localhost:8000")
11
+
12
+ QUERIES = [
13
+ "EGFR inhibitor",
14
+ "BRCA1 breast cancer",
15
+ "kinase inhibitor therapy",
16
+ "TP53 mutation cancer",
17
+ "lung cancer EGFR signaling",
18
+ ]
19
+
20
+
21
+ def run_batch(use_mmr: bool, runs: int = 10):
22
+ latencies = []
23
+ diversities = []
24
+ for i in range(runs):
25
+ q = QUERIES[i % len(QUERIES)]
26
+ t0 = time.perf_counter()
27
+ r = requests.post(
28
+ f"{BASE_URL}/api/search",
29
+ json={"query": q, "top_k": 20, "use_mmr": use_mmr, "modality": "auto"},
30
+ timeout=30,
31
+ )
32
+ dt = (time.perf_counter() - t0) * 1000.0
33
+ if r.status_code != 200:
34
+ continue
35
+ data = r.json()
36
+ latencies.append(dt)
37
+ if data.get("diversity_score") is not None:
38
+ diversities.append(float(data.get("diversity_score") or 0))
39
+ return latencies, diversities
40
+
41
+
42
+ if __name__ == "__main__":
43
+ print("MMR Benchmark")
44
+ lat_no, div_no = run_batch(False, runs=10)
45
+ lat_yes, div_yes = run_batch(True, runs=10)
46
+
47
+ def _stats(xs):
48
+ if not xs:
49
+ return "n/a"
50
+ return f"p50={statistics.median(xs):.1f}ms avg={statistics.mean(xs):.1f}ms"
51
+
52
+ print(f"MMR OFF: {_stats(lat_no)} | diversity avg={statistics.mean(div_no) if div_no else 'n/a'}")
53
+ print(f"MMR ON : {_stats(lat_yes)} | diversity avg={statistics.mean(div_yes) if div_yes else 'n/a'}")
scripts/benchmark_search_api.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Benchmark /api/search latency and error rate.
4
+
5
+ Usage:
6
+ python scripts/benchmark_search_api.py --runs 50 --concurrency 5
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import concurrent.futures as cf
13
+ import statistics
14
+ import time
15
+ from typing import Any, Dict, List, Tuple
16
+
17
+ import requests
18
+
19
+
20
+ DEFAULT_QUERIES = [
21
+ "EGFR inhibitor",
22
+ "BRCA1 breast cancer",
23
+ "kinase inhibitor therapy",
24
+ "TP53 mutation cancer",
25
+ "lung cancer EGFR signaling",
26
+ ]
27
+
28
+
29
+ def _one(base_url: str, query: str, top_k: int, use_mmr: bool) -> Tuple[bool, float, str]:
30
+ t0 = time.perf_counter()
31
+ try:
32
+ r = requests.post(
33
+ f"{base_url}/api/search",
34
+ json={"query": query, "top_k": top_k, "use_mmr": use_mmr, "modality": "auto"},
35
+ timeout=30,
36
+ )
37
+ dt_ms = (time.perf_counter() - t0) * 1000.0
38
+ if r.status_code != 200:
39
+ return False, dt_ms, f"HTTP {r.status_code}"
40
+ return True, dt_ms, ""
41
+ except Exception as e:
42
+ dt_ms = (time.perf_counter() - t0) * 1000.0
43
+ return False, dt_ms, str(e)
44
+
45
+
46
+ def main() -> int:
47
+ ap = argparse.ArgumentParser()
48
+ ap.add_argument("--base-url", default="http://localhost:8000")
49
+ ap.add_argument("--runs", type=int, default=50)
50
+ ap.add_argument("--concurrency", type=int, default=5)
51
+ ap.add_argument("--top-k", type=int, default=20)
52
+ ap.add_argument("--mmr", action="store_true", help="Enable MMR")
53
+ args = ap.parse_args()
54
+
55
+ queries = (DEFAULT_QUERIES * ((args.runs // len(DEFAULT_QUERIES)) + 1))[: args.runs]
56
+
57
+ latencies: List[float] = []
58
+ errors: List[str] = []
59
+
60
+ with cf.ThreadPoolExecutor(max_workers=args.concurrency) as ex:
61
+ futures = [
62
+ ex.submit(_one, args.base_url, q, args.top_k, bool(args.mmr))
63
+ for q in queries
64
+ ]
65
+ for f in cf.as_completed(futures):
66
+ ok, dt_ms, err = f.result()
67
+ latencies.append(dt_ms)
68
+ if not ok:
69
+ errors.append(err)
70
+
71
+ latencies.sort()
72
+ p50 = latencies[int(0.50 * (len(latencies) - 1))]
73
+ p95 = latencies[int(0.95 * (len(latencies) - 1))]
74
+ p99 = latencies[int(0.99 * (len(latencies) - 1))]
75
+
76
+ print("=" * 60)
77
+ print("BioFlow /api/search Benchmark")
78
+ print("=" * 60)
79
+ print(f"Runs: {args.runs} | Concurrency: {args.concurrency} | top_k: {args.top_k} | mmr: {bool(args.mmr)}")
80
+ print(f"OK: {args.runs - len(errors)} | Errors: {len(errors)}")
81
+ print(f"p50: {p50:.1f}ms | p95: {p95:.1f}ms | p99: {p99:.1f}ms | mean: {statistics.mean(latencies):.1f}ms")
82
+ if errors:
83
+ print("Sample errors:")
84
+ for e in errors[:5]:
85
+ print(f" - {e}")
86
+
87
+ # Non-zero exit on errors to allow CI usage.
88
+ return 1 if errors else 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
93
+
scripts/evaluate_retrieval.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Evaluate retrieval quality against a small benchmark file.
4
+
5
+ Benchmark format (JSON):
6
+ {
7
+ "queries": [
8
+ {
9
+ "name": "egfr_lung",
10
+ "query": "EGFR lung cancer",
11
+ "modality": "auto",
12
+ "top_k": 20,
13
+ "relevant_ids": ["..."],
14
+ "relevance_by_id": {"...": 1.0, "...": 2.0}
15
+ }
16
+ ],
17
+ "k": 10
18
+ }
19
+
20
+ Notes:
21
+ - `relevant_ids` is used for Recall@k and MRR@k.
22
+ - `relevance_by_id` is used for nDCG@k.
23
+ - You can provide either or both.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ from pathlib import Path
31
+ from typing import Any, Dict, List, Set
32
+
33
+ import requests
34
+
35
+ from bioflow.evaluation.metrics import mrr_at_k, ndcg_at_k, recall_at_k
36
+
37
+
38
+ def main() -> int:
39
+ ap = argparse.ArgumentParser()
40
+ ap.add_argument("--benchmark", required=True, help="Path to benchmark JSON")
41
+ ap.add_argument("--base-url", default="http://localhost:8000")
42
+ args = ap.parse_args()
43
+
44
+ bench = json.loads(Path(args.benchmark).read_text(encoding="utf-8"))
45
+ k = int(bench.get("k", 10))
46
+ queries = bench.get("queries", [])
47
+ if not queries:
48
+ raise SystemExit("Benchmark has no queries")
49
+
50
+ recalls: List[float] = []
51
+ mrrs: List[float] = []
52
+ ndcgs: List[float] = []
53
+
54
+ for q in queries:
55
+ query = q["query"]
56
+ modality = q.get("modality", "auto")
57
+ top_k = int(q.get("top_k", max(k, 20)))
58
+
59
+ r = requests.post(
60
+ f"{args.base_url}/api/search",
61
+ json={"query": query, "modality": modality, "top_k": top_k, "use_mmr": False},
62
+ timeout=60,
63
+ )
64
+ r.raise_for_status()
65
+ data = r.json()
66
+
67
+ ranked_ids = [str(item.get("id")) for item in data.get("results", []) if item.get("id") is not None]
68
+
69
+ relevant_ids = set(map(str, q.get("relevant_ids", [])))
70
+ relevance_by_id = {str(k): float(v) for k, v in (q.get("relevance_by_id", {}) or {}).items()}
71
+
72
+ if relevant_ids:
73
+ recalls.append(recall_at_k(relevant_ids, ranked_ids, k))
74
+ mrrs.append(mrr_at_k(relevant_ids, ranked_ids, k))
75
+
76
+ if relevance_by_id:
77
+ ndcgs.append(ndcg_at_k(relevance_by_id, ranked_ids, k))
78
+
79
+ print(f"- {q.get('name', query[:30])}: got={len(ranked_ids)} recall@{k}={recalls[-1] if relevant_ids else 'n/a'} mrr@{k}={mrrs[-1] if relevant_ids else 'n/a'} ndcg@{k}={ndcgs[-1] if relevance_by_id else 'n/a'}")
80
+
81
+ def _avg(xs: List[float]) -> float:
82
+ return sum(xs) / float(len(xs)) if xs else 0.0
83
+
84
+ print("=" * 60)
85
+ print(f"Aggregate (@{k})")
86
+ if recalls:
87
+ print(f"Recall: {_avg(recalls):.4f}")
88
+ print(f"MRR: {_avg(mrrs):.4f}")
89
+ if ndcgs:
90
+ print(f"nDCG: {_avg(ndcgs):.4f}")
91
+ if not (recalls or ndcgs):
92
+ print("No relevance labels provided; nothing to score.")
93
+
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())
99
+
scripts/evidence_audit.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Audit evidence-link coverage in /api/search results.
4
+ """
5
+ import os
6
+ import requests
7
+
8
+ BASE_URL = os.getenv("BIOFLOW_API_URL", "http://localhost:8000")
9
+
10
+ QUERIES = [
11
+ "EGFR inhibitor",
12
+ "BRCA1 breast cancer",
13
+ "kinase inhibitor therapy",
14
+ "TP53 mutation cancer",
15
+ ]
16
+
17
+
18
+ def main():
19
+ total = 0
20
+ with_evidence = 0
21
+
22
+ for q in QUERIES:
23
+ r = requests.post(
24
+ f"{BASE_URL}/api/search",
25
+ json={"query": q, "top_k": 10, "use_mmr": True},
26
+ timeout=30,
27
+ )
28
+ if r.status_code != 200:
29
+ print(f"[SKIP] {q} -> {r.status_code}")
30
+ continue
31
+ data = r.json()
32
+ for item in data.get("results", []):
33
+ total += 1
34
+ if item.get("evidence_links"):
35
+ with_evidence += 1
36
+
37
+ if total == 0:
38
+ print("No results returned; evidence audit skipped.")
39
+ return 0
40
+
41
+ coverage = (with_evidence / total) * 100.0
42
+ print(f"Evidence coverage: {with_evidence}/{total} ({coverage:.1f}%)")
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ raise SystemExit(main())
48
+
scripts/run_tests.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ CI-style test runner for core BioFlow checks.
4
+ """
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ TESTS = [
11
+ ["python", "test_search_api.py"],
12
+ ["python", "test_agent_api.py"],
13
+ ["python", "test_search_filters.py"],
14
+ ["python", "test_phase4_ui.py"],
15
+ ["python", "test_ingestion_api.py"],
16
+ ]
17
+
18
+
19
+ def main() -> int:
20
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
+ os.chdir(root)
22
+ failed = False
23
+
24
+ for cmd in TESTS:
25
+ print("=" * 80)
26
+ print("Running:", " ".join(cmd))
27
+ result = subprocess.run(cmd, check=False)
28
+ if result.returncode != 0:
29
+ failed = True
30
+
31
+ return 1 if failed else 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ raise SystemExit(main())
36
+