Spaces:
Sleeping
Sleeping
Commit ·
673a52e
1
Parent(s): ca41776
Fix explorer/ingestion UI and 3D endpoints
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +2 -0
- ROADMAP.md +6 -0
- SCOPE_LOCK.md +38 -0
- UC4_GAP_ANALYSIS.md +341 -0
- bioflow/agents/workflow.py +57 -4
- bioflow/api/qdrant_service.py +214 -19
- bioflow/api/server.py +820 -49
- bioflow/app.py +0 -569
- bioflow/demo.py +6 -3
- bioflow/evaluation/__init__.py +21 -0
- bioflow/evaluation/metrics.py +82 -0
- bioflow/ingestion/pubmed_ingestor.py +12 -0
- bioflow/pipeline.py +3 -0
- bioflow/qdrant_manager.py +3 -0
- bioflow/search/enhanced_search.py +234 -27
- bioflow/ui/__init__.py +0 -15
- bioflow/ui/app.py +0 -61
- bioflow/ui/components.py +0 -481
- bioflow/ui/config.py +0 -583
- bioflow/ui/pages/__init__.py +0 -5
- bioflow/ui/pages/data.py +0 -163
- bioflow/ui/pages/discovery.py +0 -165
- bioflow/ui/pages/explorer.py +0 -127
- bioflow/ui/pages/home.py +0 -213
- bioflow/ui/pages/settings.py +0 -192
- bioflow/ui/requirements.txt +0 -31
- docs/BIOFLOW_OBM_REPORT.md +12 -14
- docs/COMPLIANCE_REPORT.md +36 -0
- docs/FRONTEND_FALLBACKS.md +40 -0
- docs/INGESTION_GUIDE.md +95 -0
- docs/METADATA_SCHEMA.md +62 -0
- docs/OBSERVABILITY.md +45 -0
- docs/ROADMAP.md +14 -68
- launch_bioflow.bat +10 -2
- launch_bioflow_full.bat +1 -1
- launch_ui.py +11 -19
- open_biomed/__init__.py +17 -6
- open_biomed/core/llm_request.py +53 -42
- open_biomed/data/__init__.py +7 -2
- open_biomed/data/molecule.py +12 -3
- qdrant_data/.lock +0 -1
- qdrant_data/collection/bioflow_memory/storage.sqlite +0 -3
- qdrant_data/collection/molecules/storage.sqlite +0 -3
- qdrant_data/meta.json +0 -1
- requirements.txt +2 -3
- scripts/benchmark_mmr.py +53 -0
- scripts/benchmark_search_api.py +93 -0
- scripts/evaluate_retrieval.py +99 -0
- scripts/evidence_audit.py +48 -0
- 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 |
-
|
| 488 |
-
|
| 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 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
| 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="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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":
|
| 490 |
-
"
|
|
|
|
|
|
|
| 491 |
})
|
| 492 |
return {
|
| 493 |
"molecules": molecules,
|
| 494 |
-
"total":
|
| 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 =
|
| 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":
|
| 522 |
-
"name":
|
|
|
|
|
|
|
| 523 |
"length": len(r.content),
|
| 524 |
})
|
| 525 |
return {
|
| 526 |
"proteins": proteins,
|
| 527 |
-
"total":
|
| 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(
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 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":
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
| 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
|
| 249 |
-
print("
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
from bioflow.core.base import Modality
|
| 182 |
-
modality_enum = self._get_modality_enum(
|
| 183 |
-
|
| 184 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
-
mmr_results =
|
| 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=
|
| 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
|
| 320 |
|
| 321 |
# Build filter conditions
|
| 322 |
-
|
| 323 |
|
| 324 |
if filters.modality:
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
if filters.source:
|
| 331 |
-
|
| 332 |
key="source",
|
| 333 |
match=MatchValue(value=filters.source)
|
| 334 |
))
|
| 335 |
|
| 336 |
if filters.sources:
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
|
|
|
| 340 |
|
| 341 |
if filters.organism:
|
| 342 |
-
|
| 343 |
key="organism",
|
| 344 |
match=MatchValue(value=filters.organism)
|
| 345 |
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
# Build filter
|
| 348 |
-
query_filter =
|
|
|
|
|
|
|
| 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=
|
| 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':
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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] *
|
| 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
|
| 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=
|
| 438 |
evidence_links=[],
|
| 439 |
-
source_type=
|
| 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
|
| 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
|
| 144 |
-
|
| 145 |
-
Interface interactive complète avec :
|
| 146 |
|
| 147 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 | `
|
| 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
|
| 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] **
|
| 102 |
-
-
|
| 103 |
-
-
|
| 104 |
-
-
|
| 105 |
-
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
-
|
| 111 |
-
-
|
| 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 |
-
- **
|
| 171 |
-
- **
|
| 172 |
-
- **
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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\
|
| 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
|
| 6 |
|
| 7 |
Usage:
|
| 8 |
python launch_ui.py
|
| 9 |
-
python launch_ui.py --port
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
if not
|
| 26 |
-
print(f"❌ Error:
|
| 27 |
sys.exit(1)
|
| 28 |
|
| 29 |
# Parse arguments
|
| 30 |
-
port =
|
| 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 |
-
|
| 40 |
-
|
| 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 |
-
|
| 50 |
|
| 51 |
print(f"""
|
| 52 |
╔══════════════════════════════════════════════════════════╗
|
|
@@ -59,7 +51,7 @@ def main():
|
|
| 59 |
""")
|
| 60 |
|
| 61 |
try:
|
| 62 |
-
subprocess.run(
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 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 |
-
|
| 116 |
-
|
| 117 |
-
|
| 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(
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 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 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
stream=False,
|
| 170 |
-
temperature=self.temperature
|
| 171 |
-
)
|
| 172 |
|
| 173 |
-
text=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
uvicorn===0.32.1
|
| 15 |
fastapi===0.115.5
|
| 16 |
oss2==2.19.1
|
| 17 |
-
dotenv=
|
| 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 |
+
|