Spaces:
Sleeping
Sleeping
darved2305 commited on
Commit ·
bee3c5c
1
Parent(s): 5c1c645
ui fixes, added youtube recommendations in 2D simulation page
Browse files- README.md +833 -808
- backend/app/routes/physics.py +56 -0
- backend/app/services/youtube_recommendation_service.py +296 -0
- backend/requirements.txt +3 -0
- frontend/.env.example +6 -0
- frontend/src/components/HealthGraph.css +13 -10
- frontend/src/components/HealthGraph.tsx +3 -3
- frontend/src/components/MemoryDashboard.css +5 -2
- frontend/src/components/dashboard/DashboardNavbar.css +3 -2
- frontend/src/components/dashboard/DashboardNavbar.tsx +1 -11
- frontend/src/components/physics/ExplainabilityCard.tsx +113 -45
- frontend/src/components/physics/YouTubeRecommendationsCard.css +320 -0
- frontend/src/components/physics/YouTubeRecommendationsCard.tsx +392 -0
- frontend/src/components/ui/PdfPreview.css +4 -1
- frontend/src/pages/Features.css +301 -142
- frontend/src/pages/Features.tsx +1 -0
- frontend/src/pages/PhysicsTwin.css +48 -0
- frontend/src/pages/PhysicsTwin.tsx +40 -4
- frontend/src/pages/ReportSummary.css +11 -2
- frontend/src/services/openRouterService.ts +154 -0
- frontend/src/services/youtubeService.ts +111 -0
README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
# Lumea Health Platform
|
| 2 |
|
| 3 |
A full-stack health companion platform for preventive health management. Upload medical reports, extract health metrics via OCR, track trends, and receive AI-powered health recommendations.
|
| 4 |
|
| 5 |
-
>
|
| 6 |
|
| 7 |
---
|
| 8 |
|
|
@@ -38,9 +38,9 @@ Lumea is a unified medical companion platform that enables users to:
|
|
| 38 |
- **Receive AI recommendations** based on extracted lab values and health patterns powered by OpenRouter (Claude Sonnet 4.5)
|
| 39 |
- **Compare reports over time** with AI-powered summaries and trend analysis
|
| 40 |
- **Chat with an AI assistant** grounded in your personal health data
|
| 41 |
-
- **
|
| 42 |
-
- **
|
| 43 |
-
- **
|
| 44 |
|
| 45 |
---
|
| 46 |
|
|
@@ -68,106 +68,106 @@ Lumea is a unified medical companion platform that enables users to:
|
|
| 68 |
|
| 69 |
```mermaid
|
| 70 |
flowchart TB
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
```
|
| 141 |
|
| 142 |
### Data Flow: Report Upload to Health Index
|
| 143 |
|
| 144 |
```mermaid
|
| 145 |
sequenceDiagram
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
```
|
| 172 |
|
| 173 |
---
|
|
@@ -182,6 +182,8 @@ sequenceDiagram
|
|
| 182 |
- **Condition Detection**: 10 rule-based health conditions auto-detected with severity mapping
|
| 183 |
- **Body Impact Overlay**: 2D SVG visualization showing organs affected by detected conditions
|
| 184 |
- **Smart Recommendations**: Evidence-based health actions with YouTube educational links per condition
|
|
|
|
|
|
|
| 185 |
- **Time-Series History**: View per-report organ scores and historical trends
|
| 186 |
- **Lifestyle Integration**: Incorporates self-reported profile data (sleep hours, stress level)
|
| 187 |
|
|
@@ -196,7 +198,7 @@ sequenceDiagram
|
|
| 196 |
- Tracks conditions, symptoms, medications, supplements, allergies
|
| 197 |
- Family medical history and genetic test results
|
| 198 |
- **Once completed, users are never re-asked** – profile status persists in DB
|
| 199 |
-
- "Profile Complete
|
| 200 |
- **Real-time SMS reminders** via Twilio (or mock mode for testing)
|
| 201 |
- Background scheduler processes due reminders every 60 seconds
|
| 202 |
- Default reminders auto-generated: medication, appointment, checkup, hydration
|
|
@@ -224,7 +226,7 @@ sequenceDiagram
|
|
| 224 |
- Citations from reports and observations
|
| 225 |
- WebSocket streaming for real-time responses
|
| 226 |
|
| 227 |
-
###
|
| 228 |
- **Natural voice conversations** with your health data
|
| 229 |
- **Speech-to-Text**: Browser Web Speech API for hands-free input
|
| 230 |
- **AI Processing**: Google Gemini with personalized health context
|
|
@@ -251,47 +253,47 @@ The **Memory & Knowledge Graph** system provides persistent, intelligent health
|
|
| 251 |
|
| 252 |
```mermaid
|
| 253 |
flowchart TB
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
```
|
| 296 |
|
| 297 |
### Key Features
|
|
@@ -299,9 +301,9 @@ flowchart TB
|
|
| 299 |
#### 1. **Persistent Memory (Mem0)**
|
| 300 |
|
| 301 |
- **Automatic Fact Extraction**: Converts natural language health data into structured facts
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
|
| 306 |
- **User-Scoped Storage**: Each user's memories are isolated and secure
|
| 307 |
- **Vector-Based Retrieval**: Semantic search powered by local Ollama embeddings (nomic-embed-text)
|
|
@@ -310,46 +312,46 @@ flowchart TB
|
|
| 310 |
#### 2. **Knowledge Graph (Graphiti + Neo4j)**
|
| 311 |
|
| 312 |
- **Entity Relationship Mapping**:
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
|
| 317 |
- **Temporal Reasoning**:
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
|
| 322 |
- **Contradiction Detection**:
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
|
| 327 |
- **Graph-Powered Insights**:
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
|
| 332 |
#### 3. **Interactive Visualization**
|
| 333 |
|
| 334 |
**Features Dashboard** (`/features`) provides:
|
| 335 |
|
| 336 |
- **Memory Dashboard**:
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
|
| 342 |
- **Knowledge Graph Viewer**:
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
|
| 348 |
- **AI Insights Panel**:
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
|
| 354 |
### Technical Implementation
|
| 355 |
|
|
@@ -424,7 +426,7 @@ flowchart TB
|
|
| 424 |
|
| 425 |
Lumea includes a comprehensive medicine management system that helps users find affordable generic alternatives to prescribed medicines and locate nearby pharmacies, including government-sponsored Jan Aushadhi Kendras offering subsidized medications.
|
| 426 |
|
| 427 |
-
>
|
| 428 |
|
| 429 |
### Overview
|
| 430 |
|
|
@@ -440,89 +442,89 @@ The Medicines feature enables users to:
|
|
| 440 |
### User Flow
|
| 441 |
|
| 442 |
1. **Input Medicine Information**
|
| 443 |
-
|
| 444 |
-
|
| 445 |
|
| 446 |
2. **Normalize Medicine Data**
|
| 447 |
-
|
| 448 |
-
|
| 449 |
|
| 450 |
3. **Find Substitutes**
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
|
| 458 |
4. **Locate Pharmacies** (Optional)
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
|
| 464 |
5. **Save & Track**
|
| 465 |
-
|
| 466 |
-
|
| 467 |
|
| 468 |
### Architecture Diagram
|
| 469 |
|
| 470 |
```mermaid
|
| 471 |
flowchart TB
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
```
|
| 527 |
|
| 528 |
### API Endpoints
|
|
@@ -547,47 +549,47 @@ flowchart TB
|
|
| 547 |
Request:
|
| 548 |
```json
|
| 549 |
{
|
| 550 |
-
|
| 551 |
}
|
| 552 |
```
|
| 553 |
|
| 554 |
Response:
|
| 555 |
```json
|
| 556 |
{
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
}
|
| 592 |
```
|
| 593 |
|
|
@@ -601,32 +603,32 @@ GET /api/medicines/pharmacies/nearby?lat=40.7128&lng=-74.0060&radius_m=1000&type
|
|
| 601 |
Response:
|
| 602 |
```json
|
| 603 |
{
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
}
|
| 631 |
```
|
| 632 |
|
|
@@ -636,36 +638,36 @@ Response:
|
|
| 636 |
|
| 637 |
Lumea includes a fully integrated **Voice Agent** that provides an ElevenLabs-style conversational health assistant experience. Users can speak naturally to the AI agent, which responds with personalized health guidance based on their profile, conditions, medications, allergies, and recent lab reports.
|
| 638 |
|
| 639 |
-
>
|
| 640 |
|
| 641 |
### Overview
|
| 642 |
|
| 643 |
-
The Voice Agent
|
| 644 |
|
| 645 |
**Key Capabilities**:
|
| 646 |
|
| 647 |
-
- **
|
| 648 |
-
- **
|
| 649 |
-
- **
|
| 650 |
-
- **
|
| 651 |
-
- **
|
| 652 |
-
- **
|
| 653 |
-
- **
|
| 654 |
|
| 655 |
### How It Works
|
| 656 |
|
| 657 |
-
The Voice Agent uses a
|
| 658 |
|
| 659 |
```mermaid
|
| 660 |
graph LR
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
```
|
| 670 |
|
| 671 |
#### Stage 1: Speech-to-Text (STT)
|
|
@@ -677,75 +679,75 @@ graph LR
|
|
| 677 |
#### Stage 2: AI Processing & Context Retrieval
|
| 678 |
- **LLM Provider**: Google Gemini API (`gemini-flash-latest`)
|
| 679 |
- **Health Context Integration**:
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
- **RAG Enhancement**: Queries ChromaDB vector store for relevant historical health data
|
| 687 |
- **Safety Layer**: HIPAA-compliant system prompt with strict medical guidelines:
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
|
| 693 |
#### Stage 3: Text-to-Speech (TTS)
|
| 694 |
- **Primary**: ElevenLabs TTS API
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
- **Fallback**: Browser Text-to-Speech API
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
|
| 703 |
### Real-World Benefits
|
| 704 |
|
| 705 |
**For Everyday Users**:
|
| 706 |
-
-
|
| 707 |
-
-
|
| 708 |
-
-
|
| 709 |
-
-
|
| 710 |
|
| 711 |
**For Health-Conscious Individuals**:
|
| 712 |
-
-
|
| 713 |
-
-
|
| 714 |
-
-
|
| 715 |
-
-
|
| 716 |
|
| 717 |
**Safety & Reliability**:
|
| 718 |
-
-
|
| 719 |
-
-
|
| 720 |
-
-
|
| 721 |
-
-
|
| 722 |
|
| 723 |
### API Endpoints
|
| 724 |
|
| 725 |
| Method | Endpoint | Description | Auth | Response |
|
| 726 |
|--------|----------|-------------|------|----------|
|
| 727 |
-
| GET | `/api/voice/context` | Get user health context summary with profile completeness status |
|
| 728 |
-
| POST | `/api/voice/answer` | Generate personalized AI answer using Gemini + health context |
|
| 729 |
-
| POST | `/api/voice/tts` | Convert text to speech using ElevenLabs API |
|
| 730 |
-
| GET | `/api/voice/tts/status` | Check ElevenLabs TTS configuration status |
|
| 731 |
|
| 732 |
**Context Response Details**:
|
| 733 |
```json
|
| 734 |
{
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
}
|
| 750 |
```
|
| 751 |
|
|
@@ -764,12 +766,12 @@ Add to **root `.env`** (for Docker) and **`backend/.env`** (for local developmen
|
|
| 764 |
# ===== AI/LLM Configuration =====
|
| 765 |
# Google Gemini API (Primary LLM for Voice Agent)
|
| 766 |
USE_GEMINI_FALLBACK=true
|
| 767 |
-
GEMINI_API_KEY=AIzaSy...
|
| 768 |
|
| 769 |
# ===== Text-to-Speech Configuration =====
|
| 770 |
# ElevenLabs TTS for Voice Agent (Optional - has browser fallback)
|
| 771 |
-
ELEVENLABS_API_KEY=sk_...
|
| 772 |
-
ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
| 773 |
```
|
| 774 |
|
| 775 |
#### Docker Configuration
|
|
@@ -778,13 +780,13 @@ Ensure `docker-compose.yml` includes these environment variables in the backend
|
|
| 778 |
|
| 779 |
```yaml
|
| 780 |
services:
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
```
|
| 789 |
|
| 790 |
#### Getting API Keys
|
|
@@ -803,13 +805,13 @@ services:
|
|
| 803 |
3. Create new API key (starts with `sk_...`)
|
| 804 |
4. Free tier includes: 10,000 characters/month
|
| 805 |
5. Recommended voice IDs:
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
|
| 810 |
**Free Tier Model Compatibility**:
|
| 811 |
-
-
|
| 812 |
-
-
|
| 813 |
|
| 814 |
### Technology Stack
|
| 815 |
|
|
@@ -847,19 +849,19 @@ services:
|
|
| 847 |
|
| 848 |
```bash
|
| 849 |
curl -X GET http://localhost:8000/api/voice/tts/status \
|
| 850 |
-
|
| 851 |
```
|
| 852 |
|
| 853 |
**Expected Response**:
|
| 854 |
```json
|
| 855 |
{
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
}
|
| 864 |
```
|
| 865 |
|
|
@@ -867,24 +869,24 @@ curl -X GET http://localhost:8000/api/voice/tts/status \
|
|
| 867 |
|
| 868 |
```bash
|
| 869 |
curl -X GET http://localhost:8000/api/voice/context \
|
| 870 |
-
|
| 871 |
```
|
| 872 |
|
| 873 |
**Expected Response**:
|
| 874 |
```json
|
| 875 |
{
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
}
|
| 889 |
```
|
| 890 |
|
|
@@ -892,24 +894,24 @@ curl -X GET http://localhost:8000/api/voice/context \
|
|
| 892 |
|
| 893 |
```bash
|
| 894 |
curl -X POST http://localhost:8000/api/voice/answer \
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
```
|
| 901 |
|
| 902 |
**Expected Response**:
|
| 903 |
```json
|
| 904 |
{
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
}
|
| 914 |
```
|
| 915 |
|
|
@@ -917,10 +919,10 @@ curl -X POST http://localhost:8000/api/voice/answer \
|
|
| 917 |
|
| 918 |
```bash
|
| 919 |
curl -X POST http://localhost:8000/api/voice/tts \
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
|
| 925 |
# Play the audio file
|
| 926 |
# Windows: start test_voice.mp3
|
|
@@ -933,34 +935,34 @@ curl -X POST http://localhost:8000/api/voice/tts \
|
|
| 933 |
**Emergency Detection Test**:
|
| 934 |
```bash
|
| 935 |
curl -X POST http://localhost:8000/api/voice/answer \
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
```
|
| 940 |
|
| 941 |
**Expected Response**:
|
| 942 |
```json
|
| 943 |
{
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
}
|
| 948 |
```
|
| 949 |
|
| 950 |
**Dosage Inquiry Test**:
|
| 951 |
```bash
|
| 952 |
curl -X POST http://localhost:8000/api/voice/answer \
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
```
|
| 957 |
|
| 958 |
**Expected Response**:
|
| 959 |
```json
|
| 960 |
{
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
}
|
| 965 |
```
|
| 966 |
|
|
@@ -972,20 +974,20 @@ curl -X POST http://localhost:8000/api/voice/answer \
|
|
| 972 |
|
| 973 |
**Solutions**:
|
| 974 |
1. **Check API Key Loading**:
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
|
| 979 |
2. **Verify Environment Variables**:
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
|
| 984 |
3. **Check API Key Validity**:
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
|
| 990 |
#### Issue: Gemini API 404 or 429 Errors
|
| 991 |
|
|
@@ -993,19 +995,19 @@ curl -X POST http://localhost:8000/api/voice/answer \
|
|
| 993 |
|
| 994 |
**Solutions**:
|
| 995 |
1. **Verify Model Compatibility**:
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
|
| 1000 |
2. **Check Free Tier Limits**:
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
|
| 1005 |
3. **Update Model Name** (if needed):
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
|
| 1010 |
#### Issue: "ProfileMedication object has no attribute 'medication_name'"
|
| 1011 |
|
|
@@ -1026,16 +1028,16 @@ curl -X POST http://localhost:8000/api/voice/answer \
|
|
| 1026 |
### Example Conversations
|
| 1027 |
|
| 1028 |
**General Health Query**:
|
| 1029 |
-
-
|
| 1030 |
-
-
|
| 1031 |
|
| 1032 |
**Lab Result Interpretation**:
|
| 1033 |
-
-
|
| 1034 |
-
-
|
| 1035 |
|
| 1036 |
**Medication Context**:
|
| 1037 |
-
-
|
| 1038 |
-
-
|
| 1039 |
|
| 1040 |
---
|
| 1041 |
|
|
@@ -1043,21 +1045,23 @@ curl -X POST http://localhost:8000/api/voice/answer \
|
|
| 1043 |
|
| 1044 |
Lumea's **Physics Twin** is a comprehensive digital health analysis engine that transforms raw health metrics from uploaded medical reports into deterministic organ-level health scores and real-time condition detection. It provides an interactive 3D body visualization, time-series telemetry monitoring, and AI-powered health recommendations grounded in your actual medical data.
|
| 1045 |
|
| 1046 |
-
>
|
| 1047 |
|
| 1048 |
### Overview
|
| 1049 |
|
| 1050 |
The Physics Twin delivers:
|
| 1051 |
|
| 1052 |
-
- **
|
| 1053 |
-
- **
|
| 1054 |
-
- **
|
| 1055 |
-
- **
|
| 1056 |
-
- **
|
| 1057 |
-
- **
|
| 1058 |
-
- **
|
| 1059 |
-
- **
|
| 1060 |
-
- **
|
|
|
|
|
|
|
| 1061 |
|
| 1062 |
### How It Works
|
| 1063 |
|
|
@@ -1089,50 +1093,50 @@ LAYER 3: Condition Detection & Recommendations
|
|
| 1089 |
|
| 1090 |
```mermaid
|
| 1091 |
graph TB
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
```
|
| 1137 |
|
| 1138 |
### Core Concepts
|
|
@@ -1154,15 +1158,15 @@ Each metric is **normalised to 0-1** based on clinical reference ranges:
|
|
| 1154 |
|
| 1155 |
```
|
| 1156 |
If metric_value is in reference_range [ref_min, ref_max]:
|
| 1157 |
-
|
| 1158 |
|
| 1159 |
Else if metric_value < ref_min:
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
|
| 1163 |
Else if metric_value > ref_max:
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
```
|
| 1167 |
|
| 1168 |
**Organ Score** = weighted average of present metrics:
|
|
@@ -1174,9 +1178,9 @@ score = Σ(weight_i × normalised_i) / Σ(weight_present) × 100
|
|
| 1174 |
**Overall Score** = mean of all organ scores with data
|
| 1175 |
|
| 1176 |
**Status Classification**:
|
| 1177 |
-
-
|
| 1178 |
-
-
|
| 1179 |
-
-
|
| 1180 |
|
| 1181 |
#### Metric Name Alignment
|
| 1182 |
|
|
@@ -1213,49 +1217,49 @@ This ensures consistent data flow from OCR extraction → database storage → p
|
|
| 1213 |
#### Files Modified/Created
|
| 1214 |
|
| 1215 |
1. **`app/services/physics_config.py`**: Organ & metric specifications
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
|
| 1221 |
2. **`app/routes/physics.py`**: Smart API endpoints
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
|
| 1227 |
3. **`app/services/conditions.py`**: Rule engine
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
|
| 1233 |
4. **`app/routes/telemetry.py`**: Simulated SSE stream
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
|
| 1239 |
#### Data QueryQuery Pattern (physics.py)
|
| 1240 |
|
| 1241 |
```python
|
| 1242 |
# Gather last 90 days of metrics for user
|
| 1243 |
result = await db.execute(
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
)
|
| 1253 |
|
| 1254 |
# Deduplicate: keep most recent value per metric_name
|
| 1255 |
metrics = {}
|
| 1256 |
for obs in sorted_observations:
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
```
|
| 1260 |
|
| 1261 |
### Frontend Implementation
|
|
@@ -1263,50 +1267,59 @@ for obs in sorted_observations:
|
|
| 1263 |
#### Main Components
|
| 1264 |
|
| 1265 |
1. **`PhysicsTwin.tsx`** (616 lines): Page orchestrator
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
|
| 1272 |
2. **`TwinViewer.tsx`** (297 lines): 3D body renderer
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
|
| 1279 |
|
| 1280 |
3. **`BodyImpactOverlay.tsx`** (263 lines): 2D condition visualization
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
|
| 1287 |
4. **`OrganTelemetryCard.tsx`** (159 lines): Score summary
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
|
| 1293 |
5. **`ExplainabilityCard.tsx`**: Scoring transparency
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
6. **`
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1304 |
|
| 1305 |
#### Supporting Files
|
| 1306 |
|
| 1307 |
- **`physicsApi.ts`**: API client with types for snapshots, organs, conditions
|
| 1308 |
- **`conditionsEngine.ts`**: Client-side condition detection mirroring backend rules (instant feedback on metric changes)
|
| 1309 |
- **`useTelemetryStream.ts`**: SSE hook with automatic fallback to simulated vitals if SSE unavailable
|
|
|
|
| 1310 |
|
| 1311 |
### API Endpoints
|
| 1312 |
|
|
@@ -1327,69 +1340,69 @@ for obs in sorted_observations:
|
|
| 1327 |
Response:
|
| 1328 |
```json
|
| 1329 |
{
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
}
|
| 1394 |
```
|
| 1395 |
|
|
@@ -1405,75 +1418,84 @@ data: {"timestamp":"2025-02-06T10:30:02Z","metrics":{"heart_rate":76,"systolic_b
|
|
| 1405 |
### User Experience Flow
|
| 1406 |
|
| 1407 |
1. **Page Load**:
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
|
| 1412 |
2. **3D Viewer Interaction**:
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
|
| 1418 |
3. **Body Overlay Mode**:
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
|
| 1424 |
4. **Real-Time Metrics Tab**:
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
|
| 1429 |
5. **History Tab**:
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
|
| 1434 |
6. **Recommendations**:
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
|
| 1439 |
### Key Features Breakdown
|
| 1440 |
|
| 1441 |
#### Real Data, Not Simulation
|
| 1442 |
|
| 1443 |
-
-
|
| 1444 |
-
-
|
| 1445 |
-
-
|
| 1446 |
-
-
|
| 1447 |
|
| 1448 |
#### Deterministic Scoring
|
| 1449 |
|
| 1450 |
-
-
|
| 1451 |
-
-
|
| 1452 |
-
-
|
| 1453 |
-
-
|
| 1454 |
|
| 1455 |
#### 3D Interactive Body
|
| 1456 |
|
| 1457 |
-
-
|
| 1458 |
-
-
|
| 1459 |
-
-
|
| 1460 |
-
-
|
| 1461 |
-
-
|
| 1462 |
|
| 1463 |
#### Condition Detection
|
| 1464 |
|
| 1465 |
-
-
|
| 1466 |
-
-
|
| 1467 |
-
-
|
| 1468 |
-
-
|
| 1469 |
-
-
|
| 1470 |
|
| 1471 |
#### Educational Context
|
| 1472 |
|
| 1473 |
-
-
|
| 1474 |
-
-
|
| 1475 |
-
-
|
| 1476 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1477 |
|
| 1478 |
### Performance & Optimization
|
| 1479 |
|
|
@@ -1676,31 +1698,31 @@ alembic current
|
|
| 1676 |
|
| 1677 |
```mermaid
|
| 1678 |
erDiagram
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
-
|
| 1685 |
-
|
| 1686 |
-
|
| 1687 |
-
|
| 1688 |
-
|
| 1689 |
-
|
| 1690 |
-
|
| 1691 |
-
|
| 1692 |
-
|
| 1693 |
-
|
| 1694 |
-
|
| 1695 |
-
|
| 1696 |
-
|
| 1697 |
-
|
| 1698 |
-
|
| 1699 |
-
|
| 1700 |
-
|
| 1701 |
-
|
| 1702 |
-
|
| 1703 |
-
|
| 1704 |
```
|
| 1705 |
|
| 1706 |
### Medicines Database Schema
|
|
@@ -1866,21 +1888,21 @@ erDiagram
|
|
| 1866 |
Request:
|
| 1867 |
```json
|
| 1868 |
{
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
}
|
| 1872 |
```
|
| 1873 |
|
| 1874 |
Response:
|
| 1875 |
```json
|
| 1876 |
{
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
}
|
| 1885 |
```
|
| 1886 |
|
|
@@ -1889,30 +1911,30 @@ Response:
|
|
| 1889 |
Request:
|
| 1890 |
```json
|
| 1891 |
{
|
| 1892 |
-
|
| 1893 |
-
|
| 1894 |
}
|
| 1895 |
```
|
| 1896 |
|
| 1897 |
Response:
|
| 1898 |
```json
|
| 1899 |
{
|
| 1900 |
-
|
| 1901 |
-
|
| 1902 |
-
|
| 1903 |
-
|
| 1904 |
-
|
| 1905 |
-
|
| 1906 |
-
|
| 1907 |
-
|
| 1908 |
-
|
| 1909 |
-
|
| 1910 |
-
|
| 1911 |
-
|
| 1912 |
-
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
|
| 1916 |
}
|
| 1917 |
```
|
| 1918 |
|
|
@@ -1978,24 +2000,27 @@ pytest tests/test_routes_auth.py
|
|
| 1978 |
|
| 1979 |
### Manual QA Checklist
|
| 1980 |
|
| 1981 |
-
1.
|
| 1982 |
-
2.
|
| 1983 |
-
3.
|
| 1984 |
-
4.
|
| 1985 |
-
5.
|
| 1986 |
-
6.
|
| 1987 |
-
7.
|
| 1988 |
-
8.
|
| 1989 |
-
9.
|
| 1990 |
-
10.
|
| 1991 |
-
11.
|
| 1992 |
-
12.
|
| 1993 |
-
13.
|
| 1994 |
-
14.
|
| 1995 |
-
15.
|
| 1996 |
-
16.
|
| 1997 |
-
17.
|
| 1998 |
-
18.
|
|
|
|
|
|
|
|
|
|
| 1999 |
|
| 2000 |
### Medicines Feature Testing
|
| 2001 |
|
|
@@ -2018,45 +2043,45 @@ pytest tests/test_routes_medicines.py
|
|
| 2018 |
### Medicines Manual Testing
|
| 2019 |
|
| 2020 |
1. **Test Medicine Normalization**
|
| 2021 |
-
|
| 2022 |
-
|
| 2023 |
-
|
| 2024 |
-
|
| 2025 |
-
|
| 2026 |
-
|
| 2027 |
-
|
| 2028 |
|
| 2029 |
2. **Test Substitute Search (AI-Powered)**
|
| 2030 |
-
|
| 2031 |
-
|
| 2032 |
-
|
| 2033 |
-
|
| 2034 |
-
|
| 2035 |
-
|
| 2036 |
-
|
| 2037 |
|
| 2038 |
3. **Test Pharmacy Search**
|
| 2039 |
-
|
| 2040 |
-
|
| 2041 |
-
|
| 2042 |
-
|
| 2043 |
-
|
| 2044 |
|
| 2045 |
4. **Test Save Medicine**
|
| 2046 |
-
|
| 2047 |
-
|
| 2048 |
-
|
| 2049 |
-
|
| 2050 |
-
|
| 2051 |
-
|
| 2052 |
-
|
| 2053 |
-
|
| 2054 |
-
|
| 2055 |
-
|
| 2056 |
-
|
| 2057 |
-
|
| 2058 |
-
|
| 2059 |
-
|
| 2060 |
|
| 2061 |
### Medicines Troubleshooting
|
| 2062 |
|
|
@@ -2064,58 +2089,58 @@ pytest tests/test_routes_medicines.py
|
|
| 2064 |
|
| 2065 |
- **Cause**: Medicine not in `generic_catalog` table
|
| 2066 |
- **Solution**:
|
| 2067 |
-
|
| 2068 |
-
|
| 2069 |
-
|
| 2070 |
-
|
| 2071 |
|
| 2072 |
**Problem: "Google Places API 401" or pharmacy search returns empty**
|
| 2073 |
|
| 2074 |
- **Cause**: `GOOGLE_PLACES_API_KEY` not set or API quota exceeded
|
| 2075 |
- **Solution**:
|
| 2076 |
-
|
| 2077 |
-
|
| 2078 |
-
|
| 2079 |
-
|
| 2080 |
-
|
| 2081 |
|
| 2082 |
**Problem: Slow medicine normalization or substitute search**
|
| 2083 |
|
| 2084 |
- **Cause**: Missing database indexes on high-cardinality columns
|
| 2085 |
- **Solution**:
|
| 2086 |
-
|
| 2087 |
-
|
| 2088 |
-
|
| 2089 |
-
|
| 2090 |
-
|
| 2091 |
|
| 2092 |
**Problem: Grok API errors when parsing prescription images**
|
| 2093 |
|
| 2094 |
- **Cause**: Invalid image format, API key expired, or API rate limit
|
| 2095 |
- **Solution**:
|
| 2096 |
-
|
| 2097 |
-
|
| 2098 |
-
|
| 2099 |
-
|
| 2100 |
-
|
| 2101 |
|
| 2102 |
**Problem: Pharmacy search cache not updating**
|
| 2103 |
|
| 2104 |
- **Cause**: In-memory cache TTL (1 hour) not expired
|
| 2105 |
- **Solution**:
|
| 2106 |
-
|
| 2107 |
-
|
| 2108 |
-
|
| 2109 |
-
|
| 2110 |
|
| 2111 |
**Problem: User saved medicines not persisting**
|
| 2112 |
|
| 2113 |
- **Cause**: Database migration not applied or user_id foreign key constraint
|
| 2114 |
- **Solution**:
|
| 2115 |
-
|
| 2116 |
-
|
| 2117 |
-
|
| 2118 |
-
|
| 2119 |
---
|
| 2120 |
|
| 2121 |
## Deployment
|
|
|
|
| 1 |
+
# Lumea Health Platform
|
| 2 |
|
| 3 |
A full-stack health companion platform for preventive health management. Upload medical reports, extract health metrics via OCR, track trends, and receive AI-powered health recommendations.
|
| 4 |
|
| 5 |
+
> **Disclaimer**: This platform is a support tool for personal health tracking. It does not provide medical advice, diagnosis, or treatment. Always consult a licensed healthcare professional for medical decisions.
|
| 6 |
|
| 7 |
---
|
| 8 |
|
|
|
|
| 38 |
- **Receive AI recommendations** based on extracted lab values and health patterns powered by OpenRouter (Claude Sonnet 4.5)
|
| 39 |
- **Compare reports over time** with AI-powered summaries and trend analysis
|
| 40 |
- **Chat with an AI assistant** grounded in your personal health data
|
| 41 |
+
- **Memory & Knowledge Graph**: Persistent health memory with Mem0 and interconnected insights via Neo4j/Graphiti for temporal reasoning and contradiction detection
|
| 42 |
+
- **Voice Agent**: Speak naturally to an AI health assistant for hands-free access to personalized health insights
|
| 43 |
+
- **Physics Twin**: Interactive 2D organ simulation with real-time telemetry, condition detection, and evidence-based health recommendations powered by your actual medical data
|
| 44 |
|
| 45 |
---
|
| 46 |
|
|
|
|
| 68 |
|
| 69 |
```mermaid
|
| 70 |
flowchart TB
|
| 71 |
+
subgraph Client["Frontend (React)"]
|
| 72 |
+
UI[React UI]
|
| 73 |
+
WS[WebSocket Client]
|
| 74 |
+
end
|
| 75 |
+
|
| 76 |
+
subgraph API["Backend (FastAPI)"]
|
| 77 |
+
Auth[Auth Routes]
|
| 78 |
+
Dashboard[Dashboard Routes]
|
| 79 |
+
Reports[Reports Routes]
|
| 80 |
+
Profile[Profile Routes]
|
| 81 |
+
Recommendations[Recommendations Routes]
|
| 82 |
+
AISummary[AI Summary Routes]
|
| 83 |
+
Assistant[Assistant Routes]
|
| 84 |
+
WSServer[WebSocket Server]
|
| 85 |
+
end
|
| 86 |
+
|
| 87 |
+
subgraph Services["Backend Services"]
|
| 88 |
+
OCR[PDF/OCR Extractor]
|
| 89 |
+
Classifier[Document Classifier]
|
| 90 |
+
MetricExtractor[Metric Extractor]
|
| 91 |
+
MetricsService[Metrics Service]
|
| 92 |
+
RecommendationEngine[Recommendation Engine]
|
| 93 |
+
AISummaryService[AI Summary Service]
|
| 94 |
+
RAG[RAG Service]
|
| 95 |
+
LLM[LLM Service]
|
| 96 |
+
end
|
| 97 |
+
|
| 98 |
+
subgraph External["External Services"]
|
| 99 |
+
GrokAPI[Grok API]
|
| 100 |
+
Ollama[Ollama / MedGemma]
|
| 101 |
+
Gemini[Gemini API]
|
| 102 |
+
end
|
| 103 |
+
|
| 104 |
+
subgraph Storage["Data Layer"]
|
| 105 |
+
DB[(PostgreSQL / Neon)]
|
| 106 |
+
ChromaDB[(ChromaDB Vector Store)]
|
| 107 |
+
FileStorage[File Storage]
|
| 108 |
+
end
|
| 109 |
+
|
| 110 |
+
UI --> Auth
|
| 111 |
+
UI --> Dashboard
|
| 112 |
+
UI --> Reports
|
| 113 |
+
UI --> Profile
|
| 114 |
+
UI --> Recommendations
|
| 115 |
+
UI --> AISummary
|
| 116 |
+
UI --> Assistant
|
| 117 |
+
WS <--> WSServer
|
| 118 |
+
|
| 119 |
+
Reports --> OCR
|
| 120 |
+
OCR --> Classifier
|
| 121 |
+
Classifier --> MetricExtractor
|
| 122 |
+
MetricExtractor --> MetricsService
|
| 123 |
+
MetricsService --> RecommendationEngine
|
| 124 |
+
|
| 125 |
+
AISummary --> AISummaryService
|
| 126 |
+
AISummaryService --> GrokAPI
|
| 127 |
+
Assistant --> RAG
|
| 128 |
+
RAG --> ChromaDB
|
| 129 |
+
RAG --> LLM
|
| 130 |
+
LLM --> Ollama
|
| 131 |
+
LLM --> Gemini
|
| 132 |
+
|
| 133 |
+
Auth --> DB
|
| 134 |
+
Dashboard --> DB
|
| 135 |
+
Reports --> DB
|
| 136 |
+
Reports --> FileStorage
|
| 137 |
+
Profile --> DB
|
| 138 |
+
Recommendations --> DB
|
| 139 |
+
AISummary --> DB
|
| 140 |
```
|
| 141 |
|
| 142 |
### Data Flow: Report Upload to Health Index
|
| 143 |
|
| 144 |
```mermaid
|
| 145 |
sequenceDiagram
|
| 146 |
+
participant User
|
| 147 |
+
participant Frontend
|
| 148 |
+
participant Backend
|
| 149 |
+
participant OCR
|
| 150 |
+
participant Classifier
|
| 151 |
+
participant Extractor
|
| 152 |
+
participant DB
|
| 153 |
+
participant WebSocket
|
| 154 |
+
|
| 155 |
+
User->>Frontend: Upload PDF/Image
|
| 156 |
+
Frontend->>Backend: POST /api/reports/upload
|
| 157 |
+
Backend->>DB: Create report (status: uploaded)
|
| 158 |
+
Backend-->>Frontend: {id, status: uploaded}
|
| 159 |
+
|
| 160 |
+
Backend->>OCR: Extract text (background)
|
| 161 |
+
OCR-->>Backend: Raw text
|
| 162 |
+
Backend->>Classifier: Classify document
|
| 163 |
+
Classifier-->>Backend: Category + doc_type
|
| 164 |
+
Backend->>Extractor: Extract metrics
|
| 165 |
+
Extractor-->>Backend: Observations[]
|
| 166 |
+
Backend->>DB: Save observations + update report
|
| 167 |
+
Backend->>DB: Recompute health index
|
| 168 |
+
Backend->>WebSocket: emit report_parsed
|
| 169 |
+
WebSocket-->>Frontend: report_parsed event
|
| 170 |
+
Frontend->>Frontend: Refresh UI
|
| 171 |
```
|
| 172 |
|
| 173 |
---
|
|
|
|
| 182 |
- **Condition Detection**: 10 rule-based health conditions auto-detected with severity mapping
|
| 183 |
- **Body Impact Overlay**: 2D SVG visualization showing organs affected by detected conditions
|
| 184 |
- **Smart Recommendations**: Evidence-based health actions with YouTube educational links per condition
|
| 185 |
+
- **AI Video Recommendations**: OpenRouter-powered personalized health video suggestions for each organ based on abnormal metrics, with per-organ caching
|
| 186 |
+
- **Explainability Cards**: Side-by-side "Improve Your Health" and "Why This Score?" panels showing video recommendations and scoring breakdowns
|
| 187 |
- **Time-Series History**: View per-report organ scores and historical trends
|
| 188 |
- **Lifestyle Integration**: Incorporates self-reported profile data (sleep hours, stress level)
|
| 189 |
|
|
|
|
| 198 |
- Tracks conditions, symptoms, medications, supplements, allergies
|
| 199 |
- Family medical history and genetic test results
|
| 200 |
- **Once completed, users are never re-asked** – profile status persists in DB
|
| 201 |
+
- "Profile Complete" indicator with quick-edit access via Settings page
|
| 202 |
- **Real-time SMS reminders** via Twilio (or mock mode for testing)
|
| 203 |
- Background scheduler processes due reminders every 60 seconds
|
| 204 |
- Default reminders auto-generated: medication, appointment, checkup, hydration
|
|
|
|
| 226 |
- Citations from reports and observations
|
| 227 |
- WebSocket streaming for real-time responses
|
| 228 |
|
| 229 |
+
### Voice Agent (AI Health Assistant)
|
| 230 |
- **Natural voice conversations** with your health data
|
| 231 |
- **Speech-to-Text**: Browser Web Speech API for hands-free input
|
| 232 |
- **AI Processing**: Google Gemini with personalized health context
|
|
|
|
| 253 |
|
| 254 |
```mermaid
|
| 255 |
flowchart TB
|
| 256 |
+
subgraph Input["Health Data Sources"]
|
| 257 |
+
Profile[Health Profile]
|
| 258 |
+
Reports[Medical Reports]
|
| 259 |
+
Questionnaire[User Questionnaires]
|
| 260 |
+
Assistant[AI Assistant Chats]
|
| 261 |
+
end
|
| 262 |
+
|
| 263 |
+
subgraph Memory["Mem0 Memory Layer"]
|
| 264 |
+
Extraction[Fact Extraction<br/>via Groq LLM]
|
| 265 |
+
Storage[ChromaDB<br/>Vector Store]
|
| 266 |
+
Retrieval[Semantic Search]
|
| 267 |
+
end
|
| 268 |
+
|
| 269 |
+
subgraph Graph["Graphiti Knowledge Graph"]
|
| 270 |
+
Entities[Entity Nodes<br/>conditions, metrics, meds]
|
| 271 |
+
Relationships[Temporal Relationships<br/>affects, caused_by, treats]
|
| 272 |
+
Episodes[Health Episodes<br/>time-stamped events]
|
| 273 |
+
Insights[AI-Generated Insights<br/>contradictions, patterns]
|
| 274 |
+
end
|
| 275 |
+
|
| 276 |
+
subgraph DB["Neo4j Graph Database"]
|
| 277 |
+
Nodes[Entity Nodes]
|
| 278 |
+
Edges[Relationship Edges]
|
| 279 |
+
Indexes[Vector Indexes<br/>Ollama embeddings]
|
| 280 |
+
end
|
| 281 |
+
|
| 282 |
+
Input --> Extraction
|
| 283 |
+
Extraction --> Storage
|
| 284 |
+
Storage --> Retrieval
|
| 285 |
+
|
| 286 |
+
Input --> Entities
|
| 287 |
+
Entities --> Relationships
|
| 288 |
+
Relationships --> Episodes
|
| 289 |
+
Episodes --> Insights
|
| 290 |
+
|
| 291 |
+
Entities --> Nodes
|
| 292 |
+
Relationships --> Edges
|
| 293 |
+
Edges --> Indexes
|
| 294 |
+
|
| 295 |
+
Retrieval -.->|Enriches| Recommendations[AI Recommendations]
|
| 296 |
+
Insights -.->|Powers| Dashboard[Features Dashboard]
|
| 297 |
```
|
| 298 |
|
| 299 |
### Key Features
|
|
|
|
| 301 |
#### 1. **Persistent Memory (Mem0)**
|
| 302 |
|
| 303 |
- **Automatic Fact Extraction**: Converts natural language health data into structured facts
|
| 304 |
+
- "Sleep is about 5 hours per night" → Extracted memory fact
|
| 305 |
+
- "Has condition High Blood Pressure" → Indexed and searchable
|
| 306 |
+
- "BMI is 25.2 which falls in overweight range" → Computed and stored
|
| 307 |
|
| 308 |
- **User-Scoped Storage**: Each user's memories are isolated and secure
|
| 309 |
- **Vector-Based Retrieval**: Semantic search powered by local Ollama embeddings (nomic-embed-text)
|
|
|
|
| 312 |
#### 2. **Knowledge Graph (Graphiti + Neo4j)**
|
| 313 |
|
| 314 |
- **Entity Relationship Mapping**:
|
| 315 |
+
- Conditions linked with symptoms, medications, and risk factors
|
| 316 |
+
- Lab values connected to reference ranges and health implications
|
| 317 |
+
- Lifestyle factors (sleep, diet, exercise) linked to health outcomes
|
| 318 |
|
| 319 |
- **Temporal Reasoning**:
|
| 320 |
+
- Track health changes over time (BP trend: 140/90 in Jan → 130/85 in Feb)
|
| 321 |
+
- Episode creation for significant health events
|
| 322 |
+
- Valid/expired relationships based on temporal context
|
| 323 |
|
| 324 |
- **Contradiction Detection**:
|
| 325 |
+
- Identifies conflicting health data (e.g., "no allergies" vs. "allergic to dust")
|
| 326 |
+
- Highlights diagnostic uncertainties
|
| 327 |
+
- Flags medication interactions
|
| 328 |
|
| 329 |
- **Graph-Powered Insights**:
|
| 330 |
+
- **Temporal Patterns**: "BP decreased after starting medication X"
|
| 331 |
+
- **Key Relationships**: "High BMI correlates with elevated cholesterol"
|
| 332 |
+
- **Contradictions**: "Sleep reported as both 5hrs and 8hrs - needs clarification"
|
| 333 |
|
| 334 |
#### 3. **Interactive Visualization**
|
| 335 |
|
| 336 |
**Features Dashboard** (`/features`) provides:
|
| 337 |
|
| 338 |
- **Memory Dashboard**:
|
| 339 |
+
- View all extracted health facts
|
| 340 |
+
- Search memories semantically
|
| 341 |
+
- Add/delete facts manually
|
| 342 |
+
- Real-time sync status
|
| 343 |
|
| 344 |
- **Knowledge Graph Viewer**:
|
| 345 |
+
- Interactive node-edge visualization
|
| 346 |
+
- Filter by conditions, medications, metrics
|
| 347 |
+
- Zoom/pan for detailed exploration
|
| 348 |
+
- Click nodes/edges for metadata
|
| 349 |
|
| 350 |
- **AI Insights Panel**:
|
| 351 |
+
- Temporal insights (trend analysis)
|
| 352 |
+
- Relationship insights (correlation patterns)
|
| 353 |
+
- Contradiction insights (data conflicts)
|
| 354 |
+
- Each insight includes evidence and confidence score
|
| 355 |
|
| 356 |
### Technical Implementation
|
| 357 |
|
|
|
|
| 426 |
|
| 427 |
Lumea includes a comprehensive medicine management system that helps users find affordable generic alternatives to prescribed medicines and locate nearby pharmacies, including government-sponsored Jan Aushadhi Kendras offering subsidized medications.
|
| 428 |
|
| 429 |
+
> **Medical Disclaimer**: This feature is a support tool for informational purposes only. It does NOT provide medical advice. Always consult your doctor or pharmacist before switching medicines or starting new treatments.
|
| 430 |
|
| 431 |
### Overview
|
| 432 |
|
|
|
|
| 442 |
### User Flow
|
| 443 |
|
| 444 |
1. **Input Medicine Information**
|
| 445 |
+
- User enters brand name (e.g., "Aspirin 500mg") or uploads prescription image
|
| 446 |
+
- System extracts text via OCR or Grok AI parsing
|
| 447 |
|
| 448 |
2. **Normalize Medicine Data**
|
| 449 |
+
- `MedicineNormalizer` service extracts: salt, strength, form (tablet/capsule/liquid), release type (immediate/sustained)
|
| 450 |
+
- Queries `GenericCatalog` table to validate and standardize extraction
|
| 451 |
|
| 452 |
3. **Find Substitutes**
|
| 453 |
+
- `SubstituteFinder` queries database for alternatives with 4-tier ranked matching:
|
| 454 |
+
- **Tier 1** (1.0 score): Exact match on salt + strength + form + release type
|
| 455 |
+
- **Tier 2** (0.8 score): Same salt + strength + form
|
| 456 |
+
- **Tier 3** (0.6 score): Same salt + strength
|
| 457 |
+
- **Tier 4** (0.4 score): Same salt only
|
| 458 |
+
- Results sorted by price (Jan Aushadhi first for lowest cost)
|
| 459 |
|
| 460 |
4. **Locate Pharmacies** (Optional)
|
| 461 |
+
- User enters location (latitude/longitude) and search radius
|
| 462 |
+
- `PharmacyLocator` queries Google Places API for pharmacies
|
| 463 |
+
- Results include Jan Aushadhi Kendras (government pharmacies) and private pharmacies
|
| 464 |
+
- Results cached for 1 hour to reduce API calls
|
| 465 |
|
| 466 |
5. **Save & Track**
|
| 467 |
+
- User can save medicines to personal list with notes
|
| 468 |
+
- Each save creates `UserSavedMedicine` entry for quick reference
|
| 469 |
|
| 470 |
### Architecture Diagram
|
| 471 |
|
| 472 |
```mermaid
|
| 473 |
flowchart TB
|
| 474 |
+
subgraph Frontend["Frontend (React)"]
|
| 475 |
+
MedicinesPage["Medicines.tsx Page"]
|
| 476 |
+
SubstitutePanel["Substitute Finder Panel"]
|
| 477 |
+
PharmacyPanel["Pharmacy Locator Panel"]
|
| 478 |
+
end
|
| 479 |
+
|
| 480 |
+
subgraph API["Backend API (FastAPI)"]
|
| 481 |
+
NormalizeRoute["POST /normalize<br/>POST /normalize/batch"]
|
| 482 |
+
SubstituteRoute["POST /substitutes<br/>POST /substitutes/from-text"]
|
| 483 |
+
PharmacyRoute["GET /pharmacies/nearby<br/>GET /pharmacies/{id}<br/>POST /pharmacies/{id}/click"]
|
| 484 |
+
SavedRoute["POST /saved<br/>GET /saved<br/>DELETE /saved/{id}"]
|
| 485 |
+
end
|
| 486 |
+
|
| 487 |
+
subgraph Services["Backend Services"]
|
| 488 |
+
MedicineNormalizer["MedicineNormalizer<br/>- normalize()<br/>- normalize_batch()"]
|
| 489 |
+
SubstituteFinder["SubstituteFinder<br/>- find_substitutes()<br/>- save_medicine()"]
|
| 490 |
+
PharmacyLocator["PharmacyLocator<br/>- search_nearby()<br/>- get_place_details()<br/>- 1hr cache"]
|
| 491 |
+
GrokService["GrokMedicineService<br/>- get_alternatives_for_text()"]
|
| 492 |
+
end
|
| 493 |
+
|
| 494 |
+
subgraph External["External Services"]
|
| 495 |
+
GooglePlaces["Google Places API<br/>(Pharmacy Search)"]
|
| 496 |
+
GrokAPI["Grok xAI API<br/>(Medicine Parsing)"]
|
| 497 |
+
end
|
| 498 |
+
|
| 499 |
+
subgraph Storage["Data Layer"]
|
| 500 |
+
DB[(PostgreSQL)]
|
| 501 |
+
GenericCatalog["GenericCatalog Table<br/>(600K+ medicines)"]
|
| 502 |
+
UserSavedMedicine["UserSavedMedicine Table"]
|
| 503 |
+
SubstituteQuery["SubstituteQuery Table<br/>(Analytics)"]
|
| 504 |
+
PharmacyClick["PharmacyClick Table<br/>(Analytics)"]
|
| 505 |
+
end
|
| 506 |
+
|
| 507 |
+
MedicinesPage --> SubstitutePanel
|
| 508 |
+
MedicinesPage --> PharmacyPanel
|
| 509 |
+
SubstitutePanel --> SubstituteRoute
|
| 510 |
+
PharmacyPanel --> PharmacyRoute
|
| 511 |
+
PharmacyPanel --> SavedRoute
|
| 512 |
+
|
| 513 |
+
SubstituteRoute --> GrokService
|
| 514 |
+
SubstituteRoute --> MedicineNormalizer
|
| 515 |
+
SubstituteRoute --> SubstituteFinder
|
| 516 |
+
PharmacyRoute --> PharmacyLocator
|
| 517 |
+
|
| 518 |
+
MedicineNormalizer --> DB
|
| 519 |
+
MedicineNormalizer --> GenericCatalog
|
| 520 |
+
SubstituteFinder --> DB
|
| 521 |
+
SubstituteFinder --> GenericCatalog
|
| 522 |
+
SubstituteFinder --> SubstituteQuery
|
| 523 |
+
PharmacyLocator --> GooglePlaces
|
| 524 |
+
PharmacyLocator --> PharmacyClick
|
| 525 |
+
|
| 526 |
+
GrokService --> GrokAPI
|
| 527 |
+
GrokAPI -->|Parsed results| SubstituteFinder
|
| 528 |
```
|
| 529 |
|
| 530 |
### API Endpoints
|
|
|
|
| 549 |
Request:
|
| 550 |
```json
|
| 551 |
{
|
| 552 |
+
"text": "Aspirin 500mg tablets"
|
| 553 |
}
|
| 554 |
```
|
| 555 |
|
| 556 |
Response:
|
| 557 |
```json
|
| 558 |
{
|
| 559 |
+
"original_text": "Aspirin 500mg tablets",
|
| 560 |
+
"normalized": {
|
| 561 |
+
"brand_name": "Aspirin",
|
| 562 |
+
"salt": "Acetylsalicylic Acid",
|
| 563 |
+
"strength": "500",
|
| 564 |
+
"form": "tablet",
|
| 565 |
+
"release_type": "immediate",
|
| 566 |
+
"confidence": 0.95
|
| 567 |
+
},
|
| 568 |
+
"substitutes": [
|
| 569 |
+
{
|
| 570 |
+
"rank": 1,
|
| 571 |
+
"product_name": "Jan Aushadhi Aspirin 500mg",
|
| 572 |
+
"salt": "Acetylsalicylic Acid",
|
| 573 |
+
"strength": "500",
|
| 574 |
+
"form": "tablet",
|
| 575 |
+
"mrp": "5.00",
|
| 576 |
+
"is_jan_aushadhi": true,
|
| 577 |
+
"match_score": 1.0,
|
| 578 |
+
"match_reason": "Exact match: Same salt, strength, form, and type"
|
| 579 |
+
},
|
| 580 |
+
{
|
| 581 |
+
"rank": 2,
|
| 582 |
+
"product_name": "Ecosprin 500mg",
|
| 583 |
+
"salt": "Acetylsalicylic Acid",
|
| 584 |
+
"strength": "500",
|
| 585 |
+
"form": "tablet",
|
| 586 |
+
"mrp": "8.50",
|
| 587 |
+
"is_jan_aushadhi": false,
|
| 588 |
+
"match_score": 1.0,
|
| 589 |
+
"match_reason": "Exact match: Same salt, strength, form, and type"
|
| 590 |
+
}
|
| 591 |
+
],
|
| 592 |
+
"disclaimer": "Always confirm with your doctor or pharmacist before switching medicines"
|
| 593 |
}
|
| 594 |
```
|
| 595 |
|
|
|
|
| 603 |
Response:
|
| 604 |
```json
|
| 605 |
{
|
| 606 |
+
"pharmacies": [
|
| 607 |
+
{
|
| 608 |
+
"place_id": "ChIJN1blbgBQwokRzKgy6E_B_1Q",
|
| 609 |
+
"name": "Jan Aushadhi Kendra - Downtown",
|
| 610 |
+
"address": "123 Main St, New York, NY 10001",
|
| 611 |
+
"latitude": 40.7128,
|
| 612 |
+
"longitude": -74.0060,
|
| 613 |
+
"rating": 4.7,
|
| 614 |
+
"is_open": true,
|
| 615 |
+
"is_jan_aushadhi": true,
|
| 616 |
+
"phone": "+1-212-555-0123"
|
| 617 |
+
},
|
| 618 |
+
{
|
| 619 |
+
"place_id": "ChIJrc_p_1BQwokRzKgy6E_B_2Q",
|
| 620 |
+
"name": "Metro Pharmacy",
|
| 621 |
+
"address": "456 Broadway, New York, NY 10002",
|
| 622 |
+
"latitude": 40.7150,
|
| 623 |
+
"longitude": -74.0030,
|
| 624 |
+
"rating": 4.2,
|
| 625 |
+
"is_open": true,
|
| 626 |
+
"is_jan_aushadhi": false,
|
| 627 |
+
"phone": "+1-212-555-0456"
|
| 628 |
+
}
|
| 629 |
+
],
|
| 630 |
+
"next_page_token": null,
|
| 631 |
+
"total_results": 2
|
| 632 |
}
|
| 633 |
```
|
| 634 |
|
|
|
|
| 638 |
|
| 639 |
Lumea includes a fully integrated **Voice Agent** that provides an ElevenLabs-style conversational health assistant experience. Users can speak naturally to the AI agent, which responds with personalized health guidance based on their profile, conditions, medications, allergies, and recent lab reports.
|
| 640 |
|
| 641 |
+
> **Medical Disclaimer**: The Voice Agent is an informational support tool. It does NOT provide medical diagnosis, dosage recommendations, or emergency care. Always consult licensed healthcare professionals for medical decisions.
|
| 642 |
|
| 643 |
### Overview
|
| 644 |
|
| 645 |
+
The Voice Agent enables users to interact with their health data through natural voice conversations powered by AI.
|
| 646 |
|
| 647 |
**Key Capabilities**:
|
| 648 |
|
| 649 |
+
- **Voice Interaction**: Speak naturally using browser-based speech recognition (Web Speech API)
|
| 650 |
+
- **Personalized AI Responses**: Powered by Google Gemini with access to your complete health profile
|
| 651 |
+
- **Natural Speech Output**: Voice responses via ElevenLabs TTS (with browser fallback)
|
| 652 |
+
- **Medical Safety Filters**: Detects emergency keywords, dosage inquiries, and provides appropriate safety responses
|
| 653 |
+
- **Real-time Feedback**: Visual orb animations reflect listening/thinking/speaking states
|
| 654 |
+
- **Transcript View**: Optional chat-mode panel displays conversation history
|
| 655 |
+
- **Context-Aware**: Accesses your health conditions, medications, allergies, recent reports, and RAG-indexed data
|
| 656 |
|
| 657 |
### How It Works
|
| 658 |
|
| 659 |
+
The Voice Agent uses a three-stage pipeline to deliver personalized health conversations:
|
| 660 |
|
| 661 |
```mermaid
|
| 662 |
graph LR
|
| 663 |
+
A[User Speaks] --> B[Browser STT<br/>Web Speech API]
|
| 664 |
+
B --> C[LLM Processing<br/>Google Gemini]
|
| 665 |
+
C --> D[Health Context<br/>Profile + Reports + RAG]
|
| 666 |
+
D --> C
|
| 667 |
+
C --> E[Text-to-Speech<br/>ElevenLabs API]
|
| 668 |
+
E --> F[Browser Fallback<br/>Web Speech API]
|
| 669 |
+
E --> G[User Hears Response]
|
| 670 |
+
F --> G
|
| 671 |
```
|
| 672 |
|
| 673 |
#### Stage 1: Speech-to-Text (STT)
|
|
|
|
| 679 |
#### Stage 2: AI Processing & Context Retrieval
|
| 680 |
- **LLM Provider**: Google Gemini API (`gemini-flash-latest`)
|
| 681 |
- **Health Context Integration**:
|
| 682 |
+
- User demographics (age, gender, BMI)
|
| 683 |
+
- Active medical conditions
|
| 684 |
+
- Current medications (name, dose, frequency)
|
| 685 |
+
- Known allergies and their severity
|
| 686 |
+
- Recent lab reports and observations
|
| 687 |
+
- Lifestyle factors (sleep, exercise, smoking, alcohol)
|
| 688 |
- **RAG Enhancement**: Queries ChromaDB vector store for relevant historical health data
|
| 689 |
- **Safety Layer**: HIPAA-compliant system prompt with strict medical guidelines:
|
| 690 |
+
- Never diagnose conditions
|
| 691 |
+
- Never recommend medication dosages
|
| 692 |
+
- Always suggest professional consultation
|
| 693 |
+
- Detect emergency situations and direct to 911
|
| 694 |
|
| 695 |
#### Stage 3: Text-to-Speech (TTS)
|
| 696 |
- **Primary**: ElevenLabs TTS API
|
| 697 |
+
- Model: `eleven_turbo_v2_5` (optimized for speed, free tier compatible)
|
| 698 |
+
- Voice: Rachel (`21m00Tcm4TlvDq8ikWAM`)
|
| 699 |
+
- Quality: Natural voice synthesis
|
| 700 |
- **Fallback**: Browser Text-to-Speech API
|
| 701 |
+
- Activates automatically if ElevenLabs unavailable (503 errors)
|
| 702 |
+
- Uses system voices
|
| 703 |
+
- Zero latency, no API costs
|
| 704 |
|
| 705 |
### Real-World Benefits
|
| 706 |
|
| 707 |
**For Everyday Users**:
|
| 708 |
+
- **Hands-Free Access**: Check health info while cooking, exercising, or commuting
|
| 709 |
+
- **Natural Conversations**: Ask questions in plain language, no medical jargon required
|
| 710 |
+
- **Accessibility**: Ideal for users with visual impairments or reading difficulties
|
| 711 |
+
- **Quick Insights**: Faster than navigating multiple screens and charts
|
| 712 |
|
| 713 |
**For Health-Conscious Individuals**:
|
| 714 |
+
- **Report Interpretation**: "What do my cholesterol levels mean?"
|
| 715 |
+
- **Medication Context**: "Why am I taking this medication?"
|
| 716 |
+
- **Trend Analysis**: "How has my blood pressure changed over time?"
|
| 717 |
+
- **Personalized Guidance**: Responses tailored to your specific health profile
|
| 718 |
|
| 719 |
**Safety & Reliability**:
|
| 720 |
+
- **Emergency Detection**: Recognizes crisis keywords (chest pain, can't breathe, etc.)
|
| 721 |
+
- **Dosage Protection**: Refuses to recommend medication changes
|
| 722 |
+
- **HIPAA Guidelines**: All responses include disclaimers and professional consultation advice
|
| 723 |
+
- **Fallback Mechanisms**: Graceful degradation if AI/TTS services unavailable
|
| 724 |
|
| 725 |
### API Endpoints
|
| 726 |
|
| 727 |
| Method | Endpoint | Description | Auth | Response |
|
| 728 |
|--------|----------|-------------|------|----------|
|
| 729 |
+
| GET | `/api/voice/context` | Get user health context summary with profile completeness status | Yes | `{profile_complete: bool, summary: {...}, has_personalization: bool}` |
|
| 730 |
+
| POST | `/api/voice/answer` | Generate personalized AI answer using Gemini + health context | Yes | `{answer_text: string, flags: [string], used_context: {...}}` |
|
| 731 |
+
| POST | `/api/voice/tts` | Convert text to speech using ElevenLabs API | Yes | Binary audio stream (MP3) |
|
| 732 |
+
| GET | `/api/voice/tts/status` | Check ElevenLabs TTS configuration status | Yes | `{configured: bool, voice_id: string, runtime_check: {...}}` |
|
| 733 |
|
| 734 |
**Context Response Details**:
|
| 735 |
```json
|
| 736 |
{
|
| 737 |
+
"profile_complete": true,
|
| 738 |
+
"has_personalization": true,
|
| 739 |
+
"summary": {
|
| 740 |
+
"name": "Darshan Ved",
|
| 741 |
+
"age": 49,
|
| 742 |
+
"gender": "Male",
|
| 743 |
+
"bmi": 21.5,
|
| 744 |
+
"conditions": ["Hypertension"],
|
| 745 |
+
"medications": ["Lisinopril 10mg"],
|
| 746 |
+
"allergies": ["Penicillin"],
|
| 747 |
+
"reports_count": 5,
|
| 748 |
+
"sleep_hours": 7,
|
| 749 |
+
"exercise_frequency": "3-4 times/week"
|
| 750 |
+
}
|
| 751 |
}
|
| 752 |
```
|
| 753 |
|
|
|
|
| 766 |
# ===== AI/LLM Configuration =====
|
| 767 |
# Google Gemini API (Primary LLM for Voice Agent)
|
| 768 |
USE_GEMINI_FALLBACK=true
|
| 769 |
+
GEMINI_API_KEY=AIzaSy... # Get from https://aistudio.google.com/apikey
|
| 770 |
|
| 771 |
# ===== Text-to-Speech Configuration =====
|
| 772 |
# ElevenLabs TTS for Voice Agent (Optional - has browser fallback)
|
| 773 |
+
ELEVENLABS_API_KEY=sk_... # Get from https://elevenlabs.io/
|
| 774 |
+
ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM # Rachel voice (recommended)
|
| 775 |
```
|
| 776 |
|
| 777 |
#### Docker Configuration
|
|
|
|
| 780 |
|
| 781 |
```yaml
|
| 782 |
services:
|
| 783 |
+
backend:
|
| 784 |
+
environment:
|
| 785 |
+
# ... other env vars ...
|
| 786 |
+
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
| 787 |
+
- USE_GEMINI_FALLBACK=${USE_GEMINI_FALLBACK:-true}
|
| 788 |
+
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY:-}
|
| 789 |
+
- ELEVENLABS_VOICE_ID=${ELEVENLABS_VOICE_ID:-21m00Tcm4TlvDq8ikWAM}
|
| 790 |
```
|
| 791 |
|
| 792 |
#### Getting API Keys
|
|
|
|
| 805 |
3. Create new API key (starts with `sk_...`)
|
| 806 |
4. Free tier includes: 10,000 characters/month
|
| 807 |
5. Recommended voice IDs:
|
| 808 |
+
- `21m00Tcm4TlvDq8ikWAM` - Rachel (conversational, female)
|
| 809 |
+
- `ErXwobaYiN019PkySvjV` - Antoni (clear, male)
|
| 810 |
+
- `EXAVITQu4vr4xnSDxMaL` - Bella (warm, female)
|
| 811 |
|
| 812 |
**Free Tier Model Compatibility**:
|
| 813 |
+
- Works: `gemini-flash-latest`, `gemini-1.5-flash-8b`
|
| 814 |
+
- Quota exceeded: `gemini-2.0-flash`, `gemini-2.5-flash` (requires paid tier)
|
| 815 |
|
| 816 |
### Technology Stack
|
| 817 |
|
|
|
|
| 849 |
|
| 850 |
```bash
|
| 851 |
curl -X GET http://localhost:8000/api/voice/tts/status \
|
| 852 |
+
-H "Authorization: Bearer <your_jwt_token>"
|
| 853 |
```
|
| 854 |
|
| 855 |
**Expected Response**:
|
| 856 |
```json
|
| 857 |
{
|
| 858 |
+
"configured": true,
|
| 859 |
+
"voice_id": "21m00Tcm4TlvDq8ikWAM",
|
| 860 |
+
"runtime_check": {
|
| 861 |
+
"api_key_loaded": true,
|
| 862 |
+
"api_key_prefix": "sk_d78...",
|
| 863 |
+
"voice_id": "21m00Tcm4TlvDq8ikWAM"
|
| 864 |
+
}
|
| 865 |
}
|
| 866 |
```
|
| 867 |
|
|
|
|
| 869 |
|
| 870 |
```bash
|
| 871 |
curl -X GET http://localhost:8000/api/voice/context \
|
| 872 |
+
-H "Authorization: Bearer <your_jwt_token>"
|
| 873 |
```
|
| 874 |
|
| 875 |
**Expected Response**:
|
| 876 |
```json
|
| 877 |
{
|
| 878 |
+
"profile_complete": true,
|
| 879 |
+
"has_personalization": true,
|
| 880 |
+
"summary": {
|
| 881 |
+
"name": "Darshan Ved",
|
| 882 |
+
"age": 49,
|
| 883 |
+
"gender": "Male",
|
| 884 |
+
"bmi": 21.5,
|
| 885 |
+
"conditions": ["Hypertension"],
|
| 886 |
+
"medications": ["Lisinopril"],
|
| 887 |
+
"allergies": ["Penicillin"],
|
| 888 |
+
"reports_count": 5
|
| 889 |
+
}
|
| 890 |
}
|
| 891 |
```
|
| 892 |
|
|
|
|
| 894 |
|
| 895 |
```bash
|
| 896 |
curl -X POST http://localhost:8000/api/voice/answer \
|
| 897 |
+
-H "Authorization: Bearer <your_jwt_token>" \
|
| 898 |
+
-H "Content-Type: application/json" \
|
| 899 |
+
-d '{
|
| 900 |
+
"text": "What do my cholesterol levels mean?"
|
| 901 |
+
}'
|
| 902 |
```
|
| 903 |
|
| 904 |
**Expected Response**:
|
| 905 |
```json
|
| 906 |
{
|
| 907 |
+
"answer_text": "Based on your recent lab reports, your total cholesterol is 195 mg/dL, which falls within the desirable range (below 200 mg/dL). Your LDL cholesterol is 110 mg/dL, also in the optimal range. However, I recommend discussing these results with your healthcare provider for personalized guidance based on your hypertension condition.",
|
| 908 |
+
"flags": [],
|
| 909 |
+
"used_context": {
|
| 910 |
+
"has_profile": true,
|
| 911 |
+
"conditions_count": 1,
|
| 912 |
+
"medications_count": 1,
|
| 913 |
+
"has_rag_context": true
|
| 914 |
+
}
|
| 915 |
}
|
| 916 |
```
|
| 917 |
|
|
|
|
| 919 |
|
| 920 |
```bash
|
| 921 |
curl -X POST http://localhost:8000/api/voice/tts \
|
| 922 |
+
-H "Authorization: Bearer <your_jwt_token>" \
|
| 923 |
+
-H "Content-Type: application/json" \
|
| 924 |
+
-d '{"text": "Hello! Your health data shows positive trends."}' \
|
| 925 |
+
--output test_voice.mp3
|
| 926 |
|
| 927 |
# Play the audio file
|
| 928 |
# Windows: start test_voice.mp3
|
|
|
|
| 935 |
**Emergency Detection Test**:
|
| 936 |
```bash
|
| 937 |
curl -X POST http://localhost:8000/api/voice/answer \
|
| 938 |
+
-H "Authorization: Bearer <your_jwt_token>" \
|
| 939 |
+
-H "Content-Type: application/json" \
|
| 940 |
+
-d '{"text": "I am having severe chest pain"}'
|
| 941 |
```
|
| 942 |
|
| 943 |
**Expected Response**:
|
| 944 |
```json
|
| 945 |
{
|
| 946 |
+
"answer_text": "I'm detecting words that suggest this might be an emergency situation. Please call emergency services immediately (911) or go to the nearest emergency room...",
|
| 947 |
+
"flags": ["emergency"],
|
| 948 |
+
"used_context": {}
|
| 949 |
}
|
| 950 |
```
|
| 951 |
|
| 952 |
**Dosage Inquiry Test**:
|
| 953 |
```bash
|
| 954 |
curl -X POST http://localhost:8000/api/voice/answer \
|
| 955 |
+
-H "Authorization: Bearer <your_jwt_token>" \
|
| 956 |
+
-H "Content-Type: application/json" \
|
| 957 |
+
-d '{"text": "Can I double my medication dosage?"}'
|
| 958 |
```
|
| 959 |
|
| 960 |
**Expected Response**:
|
| 961 |
```json
|
| 962 |
{
|
| 963 |
+
"answer_text": "I cannot provide specific dosage recommendations. Please consult your doctor or pharmacist before making any changes to your medications...",
|
| 964 |
+
"flags": ["dosage_inquiry"],
|
| 965 |
+
"used_context": {...}
|
| 966 |
}
|
| 967 |
```
|
| 968 |
|
|
|
|
| 974 |
|
| 975 |
**Solutions**:
|
| 976 |
1. **Check API Key Loading**:
|
| 977 |
+
```bash
|
| 978 |
+
docker exec ggw-backend python -c "from app.settings import settings; print(f'API Key: {settings.ELEVENLABS_API_KEY[:20] if settings.ELEVENLABS_API_KEY else None}...')"
|
| 979 |
+
```
|
| 980 |
|
| 981 |
2. **Verify Environment Variables**:
|
| 982 |
+
- Ensure `ELEVENLABS_API_KEY` is in root `.env` file
|
| 983 |
+
- Ensure `docker-compose.yml` includes the env var mapping
|
| 984 |
+
- Restart Docker containers: `docker compose restart backend`
|
| 985 |
|
| 986 |
3. **Check API Key Validity**:
|
| 987 |
+
```bash
|
| 988 |
+
curl https://api.elevenlabs.io/v1/voices \
|
| 989 |
+
-H "xi-api-key: sk_your_key_here"
|
| 990 |
+
```
|
| 991 |
|
| 992 |
#### Issue: Gemini API 404 or 429 Errors
|
| 993 |
|
|
|
|
| 995 |
|
| 996 |
**Solutions**:
|
| 997 |
1. **Verify Model Compatibility**:
|
| 998 |
+
```bash
|
| 999 |
+
docker exec ggw-backend python -c "import google.generativeai as genai; import os; genai.configure(api_key=os.environ['GEMINI_API_KEY']); models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]; print('\n'.join(models))"
|
| 1000 |
+
```
|
| 1001 |
|
| 1002 |
2. **Check Free Tier Limits**:
|
| 1003 |
+
- Free tier model: `gemini-flash-latest`
|
| 1004 |
+
- Paid tier models: `gemini-2.0-flash`, `gemini-2.5-flash`
|
| 1005 |
+
- Rate limits: 15 requests/minute, 1M tokens/day
|
| 1006 |
|
| 1007 |
3. **Update Model Name** (if needed):
|
| 1008 |
+
- Edit `backend/app/services/llm_service.py`
|
| 1009 |
+
- Change model to `gemini-flash-latest`
|
| 1010 |
+
- Restart backend
|
| 1011 |
|
| 1012 |
#### Issue: "ProfileMedication object has no attribute 'medication_name'"
|
| 1013 |
|
|
|
|
| 1028 |
### Example Conversations
|
| 1029 |
|
| 1030 |
**General Health Query**:
|
| 1031 |
+
- User: "What does my health profile show?"
|
| 1032 |
+
- Assistant: "You're a 49-year-old male with a healthy BMI of 21.5. Your profile shows hypertension, and you're currently taking Lisinopril. You have 5 health reports on file. Is there specific information you'd like me to explain?"
|
| 1033 |
|
| 1034 |
**Lab Result Interpretation**:
|
| 1035 |
+
- User: "Explain my recent cholesterol test"
|
| 1036 |
+
- Assistant: "Your recent cholesterol panel shows total cholesterol at 195 mg/dL, which is in the desirable range. Your LDL is 110 mg/dL and HDL is 55 mg/dL. Given your hypertension, maintaining these levels is important. Discuss with your doctor about your diet and medication effectiveness."
|
| 1037 |
|
| 1038 |
**Medication Context**:
|
| 1039 |
+
- User: "Why am I taking Lisinopril?"
|
| 1040 |
+
- Assistant: "Lisinopril is an ACE inhibitor prescribed for managing hypertension, which is noted in your health profile. It helps lower blood pressure by relaxing blood vessels. Always take it as prescribed and discuss any side effects with your healthcare provider."
|
| 1041 |
|
| 1042 |
---
|
| 1043 |
|
|
|
|
| 1045 |
|
| 1046 |
Lumea's **Physics Twin** is a comprehensive digital health analysis engine that transforms raw health metrics from uploaded medical reports into deterministic organ-level health scores and real-time condition detection. It provides an interactive 3D body visualization, time-series telemetry monitoring, and AI-powered health recommendations grounded in your actual medical data.
|
| 1047 |
|
| 1048 |
+
> **Medical Disclaimer**: The Physics Twin is a visualization and analysis support tool. It does NOT provide medical diagnosis or treatment. Values are computed from extracted lab data and should be verified by healthcare professionals. Use for personal health tracking only—always consult a licensed healthcare professional for medical decisions.
|
| 1049 |
|
| 1050 |
### Overview
|
| 1051 |
|
| 1052 |
The Physics Twin delivers:
|
| 1053 |
|
| 1054 |
+
- **Deterministic Organ Scoring**: Evidence-based scoring for 6 organs (kidney, heart, liver, lungs, brain, blood) based on reference ranges and clinical normalisation
|
| 1055 |
+
- **Real Data Integration**: Pulls metrics directly from your uploaded medical reports (not simulated) via the `Observation` table
|
| 1056 |
+
- **Interactive 3D Body**: Clickable organ hotspots with animated scoring visualizations and trend indicators
|
| 1057 |
+
- **Real-Time Telemetry**: Simulated vital signs stream via SSE (Server-Sent Events) with SpO2, heart rate, blood pressure, stress, and glucose tracking
|
| 1058 |
+
- **Condition Detection**: 10 rule-based conditions (Hypertension, Tachycardia, Kidney Stress, Liver Stress, Hyperglycemia, Sleep Deprivation, etc.) automatically detected from current metrics
|
| 1059 |
+
- **Body Impact Overlay**: 2D SVG body silhouette highlighting organs affected by detected conditions with severity-based color coding
|
| 1060 |
+
- **Smart Recommendations**: Context-aware health recommendations with YouTube educational links for each detected condition
|
| 1061 |
+
- **AI Video Recommendations**: OpenRouter-powered personalized health video suggestions for each organ based on abnormal metrics, displayed in a side-by-side "Improve Your Health" card with per-organ caching
|
| 1062 |
+
- **Explainability Panel**: Side-by-side "Why This Score?" card showing metric contributions, weights, coverage, and confidence for each organ
|
| 1063 |
+
- **Time-Series History**: View per-report organ scores, historical trends, and scored snapshots over time
|
| 1064 |
+
- **Lifestyle Integration**: Incorporates UserProfile self-reported data (sleep hours, stress level, activity level) into organ scoring
|
| 1065 |
|
| 1066 |
### How It Works
|
| 1067 |
|
|
|
|
| 1093 |
|
| 1094 |
```mermaid
|
| 1095 |
graph TB
|
| 1096 |
+
subgraph Database["PostgreSQL"]
|
| 1097 |
+
Observation["Observation Table<br/>(extracted metrics)"]
|
| 1098 |
+
UserProfile["UserProfile Table<br/>(lifestyle data)"]
|
| 1099 |
+
Report["Report Table<br/>(source documents)"]
|
| 1100 |
+
end
|
| 1101 |
+
|
| 1102 |
+
subgraph Backend["Backend Services"]
|
| 1103 |
+
PhysicsConfig["physics_config.py<br/>- Organ specs<br/>- Metric weights<br/>- Reference ranges"]
|
| 1104 |
+
PhysicsRoute["physics.py Routes<br/>- GET /latest (auto-compute)<br/>- GET /history (per-report)<br/>- POST /metrics (manual)<br/>- GET /config"]
|
| 1105 |
+
Scorer["Scoring Engine<br/>- compute_organ_score()<br/>- compute_all_organs()"]
|
| 1106 |
+
ConditionEngine["conditions.py<br/>- detect_conditions()<br/>- 10 rule-based conditions<br/>- Severity mapping<br/>- Organ mapping"]
|
| 1107 |
+
TelemetryRoute["telemetry.py Routes<br/>- GET /stream (SSE)<br/>- GET /latest<br/>- GET /history"]
|
| 1108 |
+
end
|
| 1109 |
+
|
| 1110 |
+
subgraph Frontend["Frontend Components"]
|
| 1111 |
+
PhysicsTwin["PhysicsTwin.tsx<br/>- Main page orchestrator<br/>- Tab navigation (Twin/Metrics/History)"]
|
| 1112 |
+
TwinViewer["TwinViewer.tsx<br/>- 3D body with Three.js<br/>- Organ hotspots<br/>- Auto-rotate"]
|
| 1113 |
+
BodyOverlay["BodyImpactOverlay.tsx<br/>- SVG body silhouette<br/>- Animated organ zones<br/>- Gender toggle"]
|
| 1114 |
+
OrganTelemetry["OrganTelemetryCard.tsx<br/>- Overall score display<br/>- Selected organ detail<br/>- Metric values"]
|
| 1115 |
+
Explainability["ExplainabilityCard.tsx<br/>- Scoring breakdown<br/>- Weight contributions<br/>- Coverage/confidence"]
|
| 1116 |
+
Recommendations["RecommendationsCard.tsx<br/>- Detected conditions<br/>- Evidence-based actions<br/>- YouTube links"]
|
| 1117 |
+
ConditionsEngine["conditionsEngine.ts<br/>- Client-side condition detection<br/>- Rapid re-evaluation<br/>- Trend computation"]
|
| 1118 |
+
TelemetryHook["useTelemetryStream.ts<br/>- SSE connection manager<br/>- Fallback simulation<br/>- History buffer (120 readings)"]
|
| 1119 |
+
end
|
| 1120 |
+
|
| 1121 |
+
Observation -->|Query last 90 days| PhysicsRoute
|
| 1122 |
+
UserProfile -->|Enrich with lifestyle| PhysicsRoute
|
| 1123 |
+
Report -->|Group snapshots by| PhysicsRoute
|
| 1124 |
+
|
| 1125 |
+
PhysicsRoute -->|Load config| PhysicsConfig
|
| 1126 |
+
PhysicsRoute -->|Compute scores| Scorer
|
| 1127 |
+
Scorer -->|Detect conditions| ConditionEngine
|
| 1128 |
+
|
| 1129 |
+
TelemetryRoute -->|Generate readings| TelemetryHook
|
| 1130 |
+
PhysicsRoute -->|Return snapshot| PhysicsTwin
|
| 1131 |
+
|
| 1132 |
+
PhysicsTwin -->|Render organs| TwinViewer
|
| 1133 |
+
PhysicsTwin -->|Show overlay| BodyOverlay
|
| 1134 |
+
PhysicsTwin -->|Display score| OrganTelemetry
|
| 1135 |
+
PhysicsTwin -->|Show breakdown| Explainability
|
| 1136 |
+
PhysicsTwin -->|Detect conditions| ConditionsEngine
|
| 1137 |
+
ConditionsEngine -->|Map to organs| BodyOverlay
|
| 1138 |
+
ConditionsEngine -->|Show recommendations| Recommendations
|
| 1139 |
+
TelemetryHook -->|Stream vitals| PhysicsTwin
|
| 1140 |
```
|
| 1141 |
|
| 1142 |
### Core Concepts
|
|
|
|
| 1158 |
|
| 1159 |
```
|
| 1160 |
If metric_value is in reference_range [ref_min, ref_max]:
|
| 1161 |
+
normalised_score = 1.0 (perfect)
|
| 1162 |
|
| 1163 |
Else if metric_value < ref_min:
|
| 1164 |
+
normalised_score = (metric_value - abs_min) / (ref_min - abs_min)
|
| 1165 |
+
(linear decay to 0 at absolute minimum)
|
| 1166 |
|
| 1167 |
Else if metric_value > ref_max:
|
| 1168 |
+
normalised_score = 1.0 - (metric_value - ref_max) / (abs_max - ref_max)
|
| 1169 |
+
(linear decay to 0 at absolute maximum)
|
| 1170 |
```
|
| 1171 |
|
| 1172 |
**Organ Score** = weighted average of present metrics:
|
|
|
|
| 1178 |
**Overall Score** = mean of all organ scores with data
|
| 1179 |
|
| 1180 |
**Status Classification**:
|
| 1181 |
+
- **Healthy**: 75-100
|
| 1182 |
+
- **Watch**: 50-74
|
| 1183 |
+
- **Risk**: 0-49
|
| 1184 |
|
| 1185 |
#### Metric Name Alignment
|
| 1186 |
|
|
|
|
| 1217 |
#### Files Modified/Created
|
| 1218 |
|
| 1219 |
1. **`app/services/physics_config.py`**: Organ & metric specifications
|
| 1220 |
+
- 6 organ definitions with metric weights
|
| 1221 |
+
- Reference ranges & absolute bounds per metric
|
| 1222 |
+
- `compute_organ_score()`: Deterministic scoring function
|
| 1223 |
+
- `compute_all_organs()`: Multi-organ aggregation
|
| 1224 |
|
| 1225 |
2. **`app/routes/physics.py`**: Smart API endpoints
|
| 1226 |
+
- `GET /api/physics/latest`: Auto-compute from real DB data (90-day window)
|
| 1227 |
+
- `GET /api/physics/history`: Per-report snapshots
|
| 1228 |
+
- `POST /api/physics/metrics`: Manual submission (fallback)
|
| 1229 |
+
- `GET /api/physics/config`: Frontend config download
|
| 1230 |
|
| 1231 |
3. **`app/services/conditions.py`**: Rule engine
|
| 1232 |
+
- 10 `ConditionRule` definitions
|
| 1233 |
+
- `detect_conditions()`: Main evaluation function
|
| 1234 |
+
- Threshold checking with severity mapping
|
| 1235 |
+
- Organ affection mapping
|
| 1236 |
|
| 1237 |
4. **`app/routes/telemetry.py`**: Simulated SSE stream
|
| 1238 |
+
- Baseline vital signs (heart_rate, bp, spo2, glucose, etc.)
|
| 1239 |
+
- Per-user simulation state tracking
|
| 1240 |
+
- Realistic jitter + periodic spikes
|
| 1241 |
+
- `GET /stream` SSE endpoint
|
| 1242 |
|
| 1243 |
#### Data QueryQuery Pattern (physics.py)
|
| 1244 |
|
| 1245 |
```python
|
| 1246 |
# Gather last 90 days of metrics for user
|
| 1247 |
result = await db.execute(
|
| 1248 |
+
select(Observation)
|
| 1249 |
+
.where(
|
| 1250 |
+
and_(
|
| 1251 |
+
Observation.user_id == user_id,
|
| 1252 |
+
Observation.observed_at >= cutoff # 90 days ago
|
| 1253 |
+
)
|
| 1254 |
+
)
|
| 1255 |
+
.order_by(desc(Observation.observed_at))
|
| 1256 |
)
|
| 1257 |
|
| 1258 |
# Deduplicate: keep most recent value per metric_name
|
| 1259 |
metrics = {}
|
| 1260 |
for obs in sorted_observations:
|
| 1261 |
+
if obs.metric_name not in seen:
|
| 1262 |
+
metrics[obs.metric_name] = float(obs.value)
|
| 1263 |
```
|
| 1264 |
|
| 1265 |
### Frontend Implementation
|
|
|
|
| 1267 |
#### Main Components
|
| 1268 |
|
| 1269 |
1. **`PhysicsTwin.tsx`** (616 lines): Page orchestrator
|
| 1270 |
+
- Three tabs: Twin View (3D/overlay), Metrics (vitals grid), History (trends + snapshots)
|
| 1271 |
+
- Auto-loads latest snapshot on mount
|
| 1272 |
+
- Streams real-time telemetry via SSE hook
|
| 1273 |
+
- Auto-detects conditions from current metrics
|
| 1274 |
+
- Handles organ selection state
|
| 1275 |
|
| 1276 |
2. **`TwinViewer.tsx`** (297 lines): 3D body renderer
|
| 1277 |
+
- Uses `@react-three/fiber` + `@react-three/drei` + Three.js
|
| 1278 |
+
- Stylized torso + head mesh
|
| 1279 |
+
- 6 animated organ orbs with onClick handlers
|
| 1280 |
+
- Auto-rotating when no organ selected
|
| 1281 |
+
- Smooth camera tween to selected organ
|
| 1282 |
+
- Responsive scaling & glow effects
|
| 1283 |
|
| 1284 |
3. **`BodyImpactOverlay.tsx`** (263 lines): 2D condition visualization
|
| 1285 |
+
- SVG body silhouette (male/female toggle)
|
| 1286 |
+
- 6 organ zones with animated pulsing
|
| 1287 |
+
- Severity-based color coding (mild/moderate/severe)
|
| 1288 |
+
- Condition pill badges at top
|
| 1289 |
+
- Gender-specific body paths
|
| 1290 |
|
| 1291 |
4. **`OrganTelemetryCard.tsx`** (159 lines): Score summary
|
| 1292 |
+
- Large animated overall score display
|
| 1293 |
+
- Selected organ detail (coverage %, status badge)
|
| 1294 |
+
- Per-metric contribution breakdown with visual bars
|
| 1295 |
+
- Last updated timestamp
|
| 1296 |
|
| 1297 |
5. **`ExplainabilityCard.tsx`**: Scoring transparency
|
| 1298 |
+
- Breakdown table: metric name, raw value, normalised score, weight
|
| 1299 |
+
- Contribution % calculation
|
| 1300 |
+
- Coverage metric (% of available metrics present)
|
| 1301 |
+
- Confidence score (weighted average)
|
| 1302 |
+
|
| 1303 |
+
6. **`YouTubeRecommendationsCard.tsx`**: AI-powered video recommendations
|
| 1304 |
+
- Generates organ-specific health video suggestions via OpenRouter (GPT-4o-mini)
|
| 1305 |
+
- Per-organ caching with `useRef<Map>` so recommendations load instantly on re-selection
|
| 1306 |
+
- AbortController + request ID pattern prevents stale API responses
|
| 1307 |
+
- Retry mechanism with cache-clearing for error recovery
|
| 1308 |
+
- Styled with `dash-card` CSS to match the project design system
|
| 1309 |
+
- Side-by-side layout with ExplainabilityCard in a 50/50 CSS grid row
|
| 1310 |
+
|
| 1311 |
+
7. **`RecommendationsCard.tsx`**: Condition actions
|
| 1312 |
+
- Dynamic pill badges grouped by severity
|
| 1313 |
+
- Evidence-based recommendations for each condition
|
| 1314 |
+
- YouTube search links for educational content
|
| 1315 |
+
- Expandable/collapsible sections
|
| 1316 |
|
| 1317 |
#### Supporting Files
|
| 1318 |
|
| 1319 |
- **`physicsApi.ts`**: API client with types for snapshots, organs, conditions
|
| 1320 |
- **`conditionsEngine.ts`**: Client-side condition detection mirroring backend rules (instant feedback on metric changes)
|
| 1321 |
- **`useTelemetryStream.ts`**: SSE hook with automatic fallback to simulated vitals if SSE unavailable
|
| 1322 |
+
- **`openRouterService.ts`**: OpenRouter API client for generating video recommendations (GPT-4o-mini, with local fallback)
|
| 1323 |
|
| 1324 |
### API Endpoints
|
| 1325 |
|
|
|
|
| 1340 |
Response:
|
| 1341 |
```json
|
| 1342 |
{
|
| 1343 |
+
"id": "550e8400-e29b-41d4-a716-446655440000_2025-02-06T10:30:00",
|
| 1344 |
+
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
| 1345 |
+
"timestamp": "2025-02-06T10:30:00",
|
| 1346 |
+
"overall_score": 72.3,
|
| 1347 |
+
"overall_status": "Watch",
|
| 1348 |
+
"data_source": "reports",
|
| 1349 |
+
"raw_metrics": {
|
| 1350 |
+
"systolic_bp": 145,
|
| 1351 |
+
"diastolic_bp": 88,
|
| 1352 |
+
"heart_rate": 78,
|
| 1353 |
+
"glucose": 115,
|
| 1354 |
+
"alt": 35,
|
| 1355 |
+
"creatinine": 1.1,
|
| 1356 |
+
"spo2": 97,
|
| 1357 |
+
"hemoglobin": 14.2,
|
| 1358 |
+
"stress_level": 5.2,
|
| 1359 |
+
"sleep_hours": 6.5
|
| 1360 |
+
},
|
| 1361 |
+
"organs": {
|
| 1362 |
+
"heart": {
|
| 1363 |
+
"score": 68.5,
|
| 1364 |
+
"status": "Watch",
|
| 1365 |
+
"coverage": 0.83,
|
| 1366 |
+
"contributions": [
|
| 1367 |
+
{
|
| 1368 |
+
"name": "systolic_bp",
|
| 1369 |
+
"value": 145,
|
| 1370 |
+
"normalised": 0.75,
|
| 1371 |
+
"weight": 0.25,
|
| 1372 |
+
"weighted": 0.1875,
|
| 1373 |
+
"unit": "mmHg"
|
| 1374 |
+
}
|
| 1375 |
+
]
|
| 1376 |
+
},
|
| 1377 |
+
"kidney": {
|
| 1378 |
+
"score": 82.1,
|
| 1379 |
+
"status": "Healthy",
|
| 1380 |
+
"coverage": 1.0,
|
| 1381 |
+
"contributions": []
|
| 1382 |
+
}
|
| 1383 |
+
},
|
| 1384 |
+
"conditions": [
|
| 1385 |
+
{
|
| 1386 |
+
"id": "hypertension",
|
| 1387 |
+
"name": "Hypertension",
|
| 1388 |
+
"severity": "moderate",
|
| 1389 |
+
"affected_organs": ["heart", "kidney", "brain"],
|
| 1390 |
+
"trigger_metrics": { "systolic_bp": 145 },
|
| 1391 |
+
"recommendations": [
|
| 1392 |
+
"Reduce sodium intake to under 2,300 mg/day",
|
| 1393 |
+
"Engage in 30 minutes of moderate exercise daily"
|
| 1394 |
+
],
|
| 1395 |
+
"youtube_queries": ["how to lower blood pressure naturally"]
|
| 1396 |
+
}
|
| 1397 |
+
],
|
| 1398 |
+
"organ_conditions": {
|
| 1399 |
+
"heart": ["hypertension"],
|
| 1400 |
+
"kidney": ["hypertension"]
|
| 1401 |
+
},
|
| 1402 |
+
"organ_severities": {
|
| 1403 |
+
"heart": "moderate",
|
| 1404 |
+
"kidney": "mild"
|
| 1405 |
+
}
|
| 1406 |
}
|
| 1407 |
```
|
| 1408 |
|
|
|
|
| 1418 |
### User Experience Flow
|
| 1419 |
|
| 1420 |
1. **Page Load**:
|
| 1421 |
+
- Auto-fetches `/api/physics/latest` → loads real data from user's reports
|
| 1422 |
+
- If user has reports: snapshot shows immediately with organ scores visible
|
| 1423 |
+
- If no reports: Shows "No health data found" overlay with option to load demo data
|
| 1424 |
|
| 1425 |
2. **3D Viewer Interaction**:
|
| 1426 |
+
- Click any organ → auto-selects, triggers camera tween to that organ
|
| 1427 |
+
- OrganTelemetryCard shows score breakdown (e.g., "Heart: 68.5% — Watch")
|
| 1428 |
+
- ExplainabilityCard shows metric contributions to selected organ score
|
| 1429 |
+
- Each metric bar shows current value vs reference range
|
| 1430 |
|
| 1431 |
3. **Body Overlay Mode**:
|
| 1432 |
+
- Click "Impact View" toggle → SVG body appears
|
| 1433 |
+
- Detected conditions pulse with color coding (mild=yellow, moderate=orange, severe=red)
|
| 1434 |
+
- Each organ zone highlights if affected by a condition
|
| 1435 |
+
- Hover shows condition name
|
| 1436 |
|
| 1437 |
4. **Real-Time Metrics Tab**:
|
| 1438 |
+
- Vital signs grid shows latest values (from telemetry SSE stream)
|
| 1439 |
+
- Trend arrows (↑ up, ↓ down, → stable) based on 10-reading window
|
| 1440 |
+
- "Score Metrics" button submits current readings for manual analysis
|
| 1441 |
|
| 1442 |
5. **History Tab**:
|
| 1443 |
+
- Line chart of selected metric over last 60 telemetry readings
|
| 1444 |
+
- Scored snapshots section lists per-report scores with timestamps
|
| 1445 |
+
- Click any snapshot to replay its organ breakdown
|
| 1446 |
|
| 1447 |
6. **Recommendations**:
|
| 1448 |
+
- Dynamically updates as conditions detected/cleared
|
| 1449 |
+
- Each condition shows severity, affected organs, evidence-based actions
|
| 1450 |
+
- YouTube links for patient education (e.g., "DASH diet for hypertension")
|
| 1451 |
|
| 1452 |
### Key Features Breakdown
|
| 1453 |
|
| 1454 |
#### Real Data, Not Simulation
|
| 1455 |
|
| 1456 |
+
- `/api/physics/latest` queries actual `Observation` table
|
| 1457 |
+
- Uses real extracted metrics from uploaded medical reports
|
| 1458 |
+
- Falls back gracefully if user has zero observations (shows empty state)
|
| 1459 |
+
- Enriches with self-reported profile data (sleep, stress)
|
| 1460 |
|
| 1461 |
#### Deterministic Scoring
|
| 1462 |
|
| 1463 |
+
- Reference ranges stored in `physics_config.py`
|
| 1464 |
+
- All calculations deterministic (no AI/ML, pure math)
|
| 1465 |
+
- Explainable: every score component shown in breakdown
|
| 1466 |
+
- Aligned with clinical normalisation standards
|
| 1467 |
|
| 1468 |
#### 3D Interactive Body
|
| 1469 |
|
| 1470 |
+
- 6 clickable organ hotspots
|
| 1471 |
+
- Smooth camera animation on selection
|
| 1472 |
+
- Real-time score badges on each organ
|
| 1473 |
+
- Auto-rotating when idle
|
| 1474 |
+
- Works on mobile and desktop
|
| 1475 |
|
| 1476 |
#### Condition Detection
|
| 1477 |
|
| 1478 |
+
- 10 rule-based conditions automatically detected
|
| 1479 |
+
- Severity mapping (mild ≤ moderate ≤ severe)
|
| 1480 |
+
- Organ affection mapping (which organs are affected?)
|
| 1481 |
+
- Real-time evaluation as metrics change
|
| 1482 |
+
- Client-side + server-side dual detection for instant feedback
|
| 1483 |
|
| 1484 |
#### Educational Context
|
| 1485 |
|
| 1486 |
+
- Recommendations tailored per condition
|
| 1487 |
+
- YouTube search links for each condition
|
| 1488 |
+
- Evidence citations (reference ranges explained)
|
| 1489 |
+
- Safety disclaimers on all pages
|
| 1490 |
+
|
| 1491 |
+
#### AI Video Recommendations (OpenRouter)
|
| 1492 |
+
|
| 1493 |
+
- Personalized health video suggestions generated via OpenRouter (GPT-4o-mini) for each organ
|
| 1494 |
+
- Video recommendations based on organ-specific abnormal metrics, not generic searches
|
| 1495 |
+
- Per-organ caching with `useRef<Map>` for instant retrieval on organ re-selection
|
| 1496 |
+
- AbortController + request ID guards prevent stale responses from overwriting current data
|
| 1497 |
+
- Fallback recommendations generated locally if API is unavailable
|
| 1498 |
+
- Displayed in a side-by-side "Improve Your Health" card alongside the "Why This Score?" explainability panel
|
| 1499 |
|
| 1500 |
### Performance & Optimization
|
| 1501 |
|
|
|
|
| 1698 |
|
| 1699 |
```mermaid
|
| 1700 |
erDiagram
|
| 1701 |
+
users ||--o{ reports : uploads
|
| 1702 |
+
users ||--o{ observations : has
|
| 1703 |
+
users ||--o{ health_metrics : has
|
| 1704 |
+
users ||--o| user_profiles : has
|
| 1705 |
+
users ||--o{ profile_conditions : has
|
| 1706 |
+
users ||--o{ profile_medications : takes
|
| 1707 |
+
users ||--o{ profile_allergies : has
|
| 1708 |
+
users ||--o{ chat_sessions : owns
|
| 1709 |
+
users ||--o{ report_ai_summaries : generates
|
| 1710 |
+
users ||--o{ report_ai_comparisons : generates
|
| 1711 |
+
users ||--o{ user_saved_medicines : saves
|
| 1712 |
+
users ||--o{ substitute_queries : searches
|
| 1713 |
+
users ||--o{ pharmacy_clicks : clicks
|
| 1714 |
+
users ||--o| user_location_consent : grants
|
| 1715 |
+
|
| 1716 |
+
user_profiles ||--o{ reminders : has
|
| 1717 |
+
reminders ||--o{ reminder_events : logs
|
| 1718 |
+
|
| 1719 |
+
reports ||--o{ observations : extracts
|
| 1720 |
+
reports ||--o{ report_ai_summaries : summarized_by
|
| 1721 |
+
|
| 1722 |
+
chat_sessions ||--o{ chat_messages : contains
|
| 1723 |
+
|
| 1724 |
+
user_saved_medicines ||--o{ generic_catalog : references
|
| 1725 |
+
substitute_queries ||--o{ generic_catalog : queries
|
| 1726 |
```
|
| 1727 |
|
| 1728 |
### Medicines Database Schema
|
|
|
|
| 1888 |
Request:
|
| 1889 |
```json
|
| 1890 |
{
|
| 1891 |
+
"email": "user@example.com",
|
| 1892 |
+
"password": "securepassword"
|
| 1893 |
}
|
| 1894 |
```
|
| 1895 |
|
| 1896 |
Response:
|
| 1897 |
```json
|
| 1898 |
{
|
| 1899 |
+
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
| 1900 |
+
"user": {
|
| 1901 |
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
| 1902 |
+
"email": "user@example.com",
|
| 1903 |
+
"full_name": "John Doe",
|
| 1904 |
+
"created_at": "2026-01-15T10:30:00Z"
|
| 1905 |
+
}
|
| 1906 |
}
|
| 1907 |
```
|
| 1908 |
|
|
|
|
| 1911 |
Request:
|
| 1912 |
```json
|
| 1913 |
{
|
| 1914 |
+
"report_id": "550e8400-e29b-41d4-a716-446655440001",
|
| 1915 |
+
"force_regenerate": false
|
| 1916 |
}
|
| 1917 |
```
|
| 1918 |
|
| 1919 |
Response:
|
| 1920 |
```json
|
| 1921 |
{
|
| 1922 |
+
"summary_json": {
|
| 1923 |
+
"title": "Blood Panel Analysis - January 2026",
|
| 1924 |
+
"highlights": {
|
| 1925 |
+
"positive": ["Hemoglobin within normal range", "Glucose levels stable"],
|
| 1926 |
+
"needs_attention": ["LDL cholesterol slightly elevated"],
|
| 1927 |
+
"next_steps": ["Consider dietary changes", "Retest in 3 months"]
|
| 1928 |
+
},
|
| 1929 |
+
"plain_language_summary": "Your blood panel shows mostly healthy values...",
|
| 1930 |
+
"key_findings": [
|
| 1931 |
+
{"item": "LDL Cholesterol", "evidence": "142 mg/dL (ref: <100)"}
|
| 1932 |
+
],
|
| 1933 |
+
"confidence": 0.85
|
| 1934 |
+
},
|
| 1935 |
+
"cached": false,
|
| 1936 |
+
"generated_at": "2026-02-02T10:30:00Z",
|
| 1937 |
+
"model_name": "grok-beta"
|
| 1938 |
}
|
| 1939 |
```
|
| 1940 |
|
|
|
|
| 2000 |
|
| 2001 |
### Manual QA Checklist
|
| 2002 |
|
| 2003 |
+
1. Register a new account
|
| 2004 |
+
2. Login and verify dashboard loads
|
| 2005 |
+
3. Upload a PDF lab report
|
| 2006 |
+
4. Verify OCR extraction completes (WebSocket notification)
|
| 2007 |
+
5. Check extracted metrics appear in reports list
|
| 2008 |
+
6. Verify health index updates on dashboard
|
| 2009 |
+
7. Complete health profile wizard
|
| 2010 |
+
8. Check recommendations generate
|
| 2011 |
+
9. Open AI Summary page, select a report
|
| 2012 |
+
10. Generate AI summary, verify it displays
|
| 2013 |
+
11. Select 2 reports of same type, generate comparison
|
| 2014 |
+
12. Try selecting mixed types, verify warning appears
|
| 2015 |
+
13. Test Medicines feature: search for a medicine (e.g., "Aspirin 500mg")
|
| 2016 |
+
14. Verify substitutes appear with prices and Jan Aushadhi options highlighted
|
| 2017 |
+
15. Test pharmacy search by entering your location
|
| 2018 |
+
16. Verify nearby pharmacies appear with ratings and distance
|
| 2019 |
+
17. Try filtering pharmacies by type (all/jan_aushadhi/generic)
|
| 2020 |
+
18. Save a medicine and verify it appears in saved list
|
| 2021 |
+
19. Open Physics Twin page, verify organ scores load from reports
|
| 2022 |
+
20. Click an organ, verify "Why This Score?" breakdown and "Improve Your Health" video recommendations appear side-by-side
|
| 2023 |
+
21. Switch organs, verify video recommendations update and cache correctly
|
| 2024 |
|
| 2025 |
### Medicines Feature Testing
|
| 2026 |
|
|
|
|
| 2043 |
### Medicines Manual Testing
|
| 2044 |
|
| 2045 |
1. **Test Medicine Normalization**
|
| 2046 |
+
```bash
|
| 2047 |
+
curl -X POST http://localhost:8000/api/medicines/normalize \
|
| 2048 |
+
-H "Authorization: Bearer <token>" \
|
| 2049 |
+
-H "Content-Type: application/json" \
|
| 2050 |
+
-d '{"text": "Aspirin 500mg tablet"}'
|
| 2051 |
+
```
|
| 2052 |
+
Expected: `NormalizedMedicine` with extracted salt, strength, form
|
| 2053 |
|
| 2054 |
2. **Test Substitute Search (AI-Powered)**
|
| 2055 |
+
```bash
|
| 2056 |
+
curl -X POST http://localhost:8000/api/medicines/substitutes/from-text \
|
| 2057 |
+
-H "Authorization: Bearer <token>" \
|
| 2058 |
+
-H "Content-Type: application/json" \
|
| 2059 |
+
-d '{"text": "Crocin 650mg for fever"}'
|
| 2060 |
+
```
|
| 2061 |
+
Expected: Ranked list of alternatives with prices
|
| 2062 |
|
| 2063 |
3. **Test Pharmacy Search**
|
| 2064 |
+
```bash
|
| 2065 |
+
curl -X GET "http://localhost:8000/api/medicines/pharmacies/nearby?lat=40.7128&lng=-74.0060&radius_m=1000&type=all" \
|
| 2066 |
+
-H "Authorization: Bearer <token>"
|
| 2067 |
+
```
|
| 2068 |
+
Expected: List of nearby pharmacies with ratings
|
| 2069 |
|
| 2070 |
4. **Test Save Medicine**
|
| 2071 |
+
```bash
|
| 2072 |
+
curl -X POST http://localhost:8000/api/medicines/saved \
|
| 2073 |
+
-H "Authorization: Bearer <token>" \
|
| 2074 |
+
-H "Content-Type: application/json" \
|
| 2075 |
+
-d '{
|
| 2076 |
+
"brand_name": "Aspirin",
|
| 2077 |
+
"salt": "Acetylsalicylic Acid",
|
| 2078 |
+
"strength": "500",
|
| 2079 |
+
"form": "tablet",
|
| 2080 |
+
"release_type": "immediate",
|
| 2081 |
+
"notes": "Take with food"
|
| 2082 |
+
}'
|
| 2083 |
+
```
|
| 2084 |
+
Expected: `UserSavedMedicine` object saved to database
|
| 2085 |
|
| 2086 |
### Medicines Troubleshooting
|
| 2087 |
|
|
|
|
| 2089 |
|
| 2090 |
- **Cause**: Medicine not in `generic_catalog` table
|
| 2091 |
- **Solution**:
|
| 2092 |
+
- Verify PMBI/Jan Aushadhi seed data was loaded during migration
|
| 2093 |
+
- Check database: `SELECT COUNT(*) FROM generic_catalog;` should show 600K+ records
|
| 2094 |
+
- Try searching by generic name instead of brand name (e.g., "Acetylsalicylic Acid" instead of "Aspirin")
|
| 2095 |
+
- Check logs for Grok API errors if using free-text search
|
| 2096 |
|
| 2097 |
**Problem: "Google Places API 401" or pharmacy search returns empty**
|
| 2098 |
|
| 2099 |
- **Cause**: `GOOGLE_PLACES_API_KEY` not set or API quota exceeded
|
| 2100 |
- **Solution**:
|
| 2101 |
+
- Verify `GOOGLE_PLACES_API_KEY` is set in `.env`: `echo $GOOGLE_PLACES_API_KEY`
|
| 2102 |
+
- Check Google Cloud Console for API key validity and quota limits
|
| 2103 |
+
- If quota exceeded, wait 24 hours or upgrade billing
|
| 2104 |
+
- During development, feature falls back to mock pharmacy data when API key is missing
|
| 2105 |
+
- Mock data is useful for frontend testing without API costs
|
| 2106 |
|
| 2107 |
**Problem: Slow medicine normalization or substitute search**
|
| 2108 |
|
| 2109 |
- **Cause**: Missing database indexes on high-cardinality columns
|
| 2110 |
- **Solution**:
|
| 2111 |
+
- Verify indexes exist on `generic_catalog`: `SELECT indexname FROM pg_indexes WHERE tablename='generic_catalog';`
|
| 2112 |
+
- Should see indexes on: `product_name`, `salt`, `user_id`
|
| 2113 |
+
- If missing, run: `alembic upgrade head` to apply latest migrations
|
| 2114 |
+
- Check database query performance: Enable `EXPLAIN ANALYZE` in PostgreSQL
|
| 2115 |
+
- Profile with: `SELECT pg_size_pretty(pg_total_relation_size('generic_catalog'));`
|
| 2116 |
|
| 2117 |
**Problem: Grok API errors when parsing prescription images**
|
| 2118 |
|
| 2119 |
- **Cause**: Invalid image format, API key expired, or API rate limit
|
| 2120 |
- **Solution**:
|
| 2121 |
+
- Verify image is clear and readable (JPG, PNG, TIFF)
|
| 2122 |
+
- Test API key directly: `curl -H "Authorization: Bearer $GROK_API_KEY" https://api.x.ai/v1/models`
|
| 2123 |
+
- Check Grok API status at https://status.x.ai
|
| 2124 |
+
- Review backend logs: `docker logs <backend-container>` or `tail uvicorn.log`
|
| 2125 |
+
- If rate-limited, implement request throttling in `medicine_normalizer.py`
|
| 2126 |
|
| 2127 |
**Problem: Pharmacy search cache not updating**
|
| 2128 |
|
| 2129 |
- **Cause**: In-memory cache TTL (1 hour) not expired
|
| 2130 |
- **Solution**:
|
| 2131 |
+
- Manually clear cache by restarting backend service
|
| 2132 |
+
- Cache is per-search key (lat/lng/radius): different locations create new cache entries
|
| 2133 |
+
- Check cache size in logs: Search for "pharmacy_cache" debug messages
|
| 2134 |
+
- To disable caching for testing, set `PharmacyLocator.CACHE_TTL_SECONDS = 0`
|
| 2135 |
|
| 2136 |
**Problem: User saved medicines not persisting**
|
| 2137 |
|
| 2138 |
- **Cause**: Database migration not applied or user_id foreign key constraint
|
| 2139 |
- **Solution**:
|
| 2140 |
+
- Verify `user_saved_medicines` table exists: `\dt user_saved_medicines` in psql
|
| 2141 |
+
- Run migrations: `cd backend && alembic upgrade head`
|
| 2142 |
+
- Verify user exists and authenticated: Check JWT token contains valid `sub` (user_id)
|
| 2143 |
+
- Check database logs for constraint errors: `docker logs <postgres-container>`
|
| 2144 |
---
|
| 2145 |
|
| 2146 |
## Deployment
|
backend/app/routes/physics.py
CHANGED
|
@@ -36,6 +36,13 @@ from app.services.conditions import (
|
|
| 36 |
get_organ_conditions,
|
| 37 |
get_organ_worst_severity,
|
| 38 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
logger = logging.getLogger(__name__)
|
| 41 |
|
|
@@ -315,3 +322,52 @@ async def get_config(user=Depends(get_current_user)):
|
|
| 315 |
],
|
| 316 |
}
|
| 317 |
return {"organs": organs}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
get_organ_conditions,
|
| 37 |
get_organ_worst_severity,
|
| 38 |
)
|
| 39 |
+
from app.services.youtube_recommendation_service import YouTubeRecommendationService
|
| 40 |
+
from app.settings import Settings
|
| 41 |
+
from functools import lru_cache
|
| 42 |
+
|
| 43 |
+
@lru_cache()
|
| 44 |
+
def get_settings():
|
| 45 |
+
return Settings()
|
| 46 |
|
| 47 |
logger = logging.getLogger(__name__)
|
| 48 |
|
|
|
|
| 322 |
],
|
| 323 |
}
|
| 324 |
return {"organs": organs}
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
class YouTubeRecommendationRequest(BaseModel):
|
| 328 |
+
"""Request body for YouTube recommendations"""
|
| 329 |
+
organ: str = Field(..., description="Organ name (kidney, heart, liver, etc.)")
|
| 330 |
+
score: float = Field(..., description="Organ health score 0-100")
|
| 331 |
+
status: str = Field(..., description="Health status (Healthy, Watch, Risk)")
|
| 332 |
+
metrics: Dict[str, float] = Field(default_factory=dict, description="Current metric values")
|
| 333 |
+
conditions: List[dict] = Field(default_factory=list, description="Detected conditions")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
class YouTubeRecommendationResponse(BaseModel):
|
| 337 |
+
"""Response with YouTube video recommendations"""
|
| 338 |
+
organ: str
|
| 339 |
+
recommendations: List[Dict[str, str]]
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@router.post("/youtube-recommendations", response_model=YouTubeRecommendationResponse)
|
| 343 |
+
async def get_youtube_recommendations(
|
| 344 |
+
body: YouTubeRecommendationRequest,
|
| 345 |
+
user=Depends(get_current_user),
|
| 346 |
+
):
|
| 347 |
+
"""
|
| 348 |
+
Generate YouTube video recommendations based on organ telemetry.
|
| 349 |
+
Uses OpenRouter AI to generate targeted search queries and returns
|
| 350 |
+
actual YouTube videos (if API key is available) or search links.
|
| 351 |
+
"""
|
| 352 |
+
try:
|
| 353 |
+
settings = get_settings()
|
| 354 |
+
service = YouTubeRecommendationService(settings)
|
| 355 |
+
|
| 356 |
+
recommendations = await service.generate_recommendations(
|
| 357 |
+
organ=body.organ,
|
| 358 |
+
score=body.score,
|
| 359 |
+
status=body.status,
|
| 360 |
+
metrics=body.metrics,
|
| 361 |
+
conditions=body.conditions,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
return {
|
| 365 |
+
"organ": body.organ,
|
| 366 |
+
"recommendations": recommendations,
|
| 367 |
+
}
|
| 368 |
+
except Exception as e:
|
| 369 |
+
logger.exception(f"Error generating YouTube recommendations: {e}")
|
| 370 |
+
raise HTTPException(
|
| 371 |
+
status_code=500,
|
| 372 |
+
detail=f"Failed to generate recommendations: {str(e)}"
|
| 373 |
+
)
|
backend/app/services/youtube_recommendation_service.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
YouTube Recommendation Service
|
| 3 |
+
|
| 4 |
+
Uses OpenRouter API to generate contextual YouTube video recommendations
|
| 5 |
+
based on organ telemetry data and detected health conditions.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from typing import Dict, List, Optional
|
| 13 |
+
import httpx
|
| 14 |
+
from googleapiclient.discovery import build
|
| 15 |
+
from googleapiclient.errors import HttpError
|
| 16 |
+
|
| 17 |
+
from app.settings import Settings
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class YouTubeRecommendationService:
|
| 23 |
+
"""Generate accurate YouTube video recommendations for health improvement"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, settings: Settings):
|
| 26 |
+
self.settings = settings
|
| 27 |
+
self.openrouter_api_key = settings.OPENROUTER_API_KEY or os.getenv("OPENROUTER_API_KEY")
|
| 28 |
+
self.youtube_api_key = settings.google_places_api_key or os.getenv("YOUTUBE_API_KEY")
|
| 29 |
+
|
| 30 |
+
if not self.openrouter_api_key:
|
| 31 |
+
logger.warning("OPENROUTER_API_KEY not configured - YouTube recommendations will use fallback")
|
| 32 |
+
|
| 33 |
+
self.openrouter_base_url = "https://openrouter.ai/api/v1/chat/completions"
|
| 34 |
+
self.timeout = 30
|
| 35 |
+
|
| 36 |
+
async def generate_recommendations(
|
| 37 |
+
self,
|
| 38 |
+
organ: str,
|
| 39 |
+
score: float,
|
| 40 |
+
status: str,
|
| 41 |
+
metrics: Dict[str, float],
|
| 42 |
+
conditions: List[Dict],
|
| 43 |
+
) -> List[Dict[str, str]]:
|
| 44 |
+
"""
|
| 45 |
+
Generate YouTube video recommendations based on organ health data.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
organ: Organ name (e.g., "kidney", "heart", "liver")
|
| 49 |
+
score: Organ health score (0-100)
|
| 50 |
+
status: Health status ("Healthy", "Watch", "Risk")
|
| 51 |
+
metrics: Current metric values affecting this organ
|
| 52 |
+
conditions: List of detected conditions affecting this organ
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
List of video recommendations with title, videoId, thumbnail, and description
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
# Generate search queries using OpenRouter
|
| 59 |
+
queries = await self._generate_search_queries(organ, score, status, metrics, conditions)
|
| 60 |
+
|
| 61 |
+
# Fetch actual YouTube videos for each query
|
| 62 |
+
recommendations = []
|
| 63 |
+
for query in queries[:3]: # Limit to top 3 queries to save API calls
|
| 64 |
+
videos = await self._search_youtube(query)
|
| 65 |
+
if videos:
|
| 66 |
+
recommendations.extend(videos[:2]) # Top 2 videos per query
|
| 67 |
+
|
| 68 |
+
# Deduplicate by video ID and limit to 5 total
|
| 69 |
+
seen_ids = set()
|
| 70 |
+
unique_recommendations = []
|
| 71 |
+
for video in recommendations:
|
| 72 |
+
if video["videoId"] not in seen_ids:
|
| 73 |
+
seen_ids.add(video["videoId"])
|
| 74 |
+
unique_recommendations.append(video)
|
| 75 |
+
if len(unique_recommendations) >= 5:
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
return unique_recommendations
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.exception(f"Error generating YouTube recommendations: {e}")
|
| 82 |
+
return self._get_fallback_recommendations(organ, conditions)
|
| 83 |
+
|
| 84 |
+
async def _generate_search_queries(
|
| 85 |
+
self,
|
| 86 |
+
organ: str,
|
| 87 |
+
score: float,
|
| 88 |
+
status: str,
|
| 89 |
+
metrics: Dict[str, float],
|
| 90 |
+
conditions: List[Dict],
|
| 91 |
+
) -> List[str]:
|
| 92 |
+
"""Use OpenRouter AI to generate targeted YouTube search queries"""
|
| 93 |
+
|
| 94 |
+
if not self.openrouter_api_key:
|
| 95 |
+
return self._get_fallback_queries(organ, conditions)
|
| 96 |
+
|
| 97 |
+
# Build context for AI
|
| 98 |
+
condition_text = ""
|
| 99 |
+
if conditions:
|
| 100 |
+
condition_names = [c.get("name", "") for c in conditions]
|
| 101 |
+
condition_text = f"Detected conditions: {', '.join(condition_names)}. "
|
| 102 |
+
|
| 103 |
+
metric_text = ""
|
| 104 |
+
if metrics:
|
| 105 |
+
metric_items = [f"{k.replace('_', ' ')}: {v:.1f}" for k, v in metrics.items()]
|
| 106 |
+
metric_text = f"Key metrics: {', '.join(metric_items)}. "
|
| 107 |
+
|
| 108 |
+
prompt = f"""You are a medical health educator. Generate 5 specific, actionable YouTube search queries for someone with:
|
| 109 |
+
|
| 110 |
+
Organ: {organ.upper()}
|
| 111 |
+
Health Score: {score:.1f}/100 ({status})
|
| 112 |
+
{condition_text}{metric_text}
|
| 113 |
+
|
| 114 |
+
Requirements:
|
| 115 |
+
1. Each query should target EVIDENCE-BASED educational content
|
| 116 |
+
2. Focus on practical improvement strategies, diet, exercise, lifestyle
|
| 117 |
+
3. Include specific terms like "doctor explains", "Mayo Clinic", "medical review"
|
| 118 |
+
4. Avoid clickbait - prioritize credible medical sources
|
| 119 |
+
5. Mix general organ health with specific condition management
|
| 120 |
+
|
| 121 |
+
Return ONLY the 5 search queries, one per line, no numbering or extra text."""
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
| 125 |
+
response = await client.post(
|
| 126 |
+
self.openrouter_base_url,
|
| 127 |
+
headers={
|
| 128 |
+
"Authorization": f"Bearer {self.openrouter_api_key}",
|
| 129 |
+
"HTTP-Referer": "https://lumea-health.com",
|
| 130 |
+
"X-Title": "Lumea Health YouTube Recommendations",
|
| 131 |
+
},
|
| 132 |
+
json={
|
| 133 |
+
"model": "openai/gpt-4o-mini", # Fast and accurate
|
| 134 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 135 |
+
"temperature": 0.7,
|
| 136 |
+
"max_tokens": 300,
|
| 137 |
+
}
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
if response.status_code == 200:
|
| 141 |
+
data = response.json()
|
| 142 |
+
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 143 |
+
queries = [q.strip() for q in content.strip().split("\n") if q.strip()]
|
| 144 |
+
logger.info(f"Generated {len(queries)} queries for {organ}: {queries}")
|
| 145 |
+
return queries[:5]
|
| 146 |
+
else:
|
| 147 |
+
logger.warning(f"OpenRouter returned status {response.status_code}")
|
| 148 |
+
return self._get_fallback_queries(organ, conditions)
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.warning(f"Error calling OpenRouter: {e}")
|
| 152 |
+
return self._get_fallback_queries(organ, conditions)
|
| 153 |
+
|
| 154 |
+
async def _search_youtube(self, query: str) -> List[Dict[str, str]]:
|
| 155 |
+
"""Search YouTube Data API v3 for videos matching the query"""
|
| 156 |
+
|
| 157 |
+
# Return search URL fallback if no API key - with generic placeholder
|
| 158 |
+
if not self.youtube_api_key:
|
| 159 |
+
logger.info(f"No YouTube API key - returning search link for: {query}")
|
| 160 |
+
# Generate a more deterministic video ID for better caching
|
| 161 |
+
video_id = f"srch{abs(hash(query)) % 1000000}"
|
| 162 |
+
return [{
|
| 163 |
+
"title": query,
|
| 164 |
+
"videoId": video_id,
|
| 165 |
+
"thumbnail": f"https://via.placeholder.com/320x180/6b9175/ffffff?text={query[:30].replace(' ', '+')}",
|
| 166 |
+
"description": "Click to search YouTube for medical videos on this topic",
|
| 167 |
+
"url": f"https://www.youtube.com/results?search_query={query.replace(' ', '+')}",
|
| 168 |
+
"channelTitle": "YouTube Search"
|
| 169 |
+
}]
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
youtube = build("youtube", "v3", developerKey=self.youtube_api_key)
|
| 173 |
+
|
| 174 |
+
search_response = youtube.search().list(
|
| 175 |
+
q=query,
|
| 176 |
+
part="id,snippet",
|
| 177 |
+
maxResults=3,
|
| 178 |
+
type="video",
|
| 179 |
+
order="relevance",
|
| 180 |
+
videoDuration="medium", # 4-20 minutes
|
| 181 |
+
videoDefinition="high",
|
| 182 |
+
relevanceLanguage="en",
|
| 183 |
+
).execute()
|
| 184 |
+
|
| 185 |
+
videos = []
|
| 186 |
+
for item in search_response.get("items", []):
|
| 187 |
+
video_id = item["id"]["videoId"]
|
| 188 |
+
snippet = item["snippet"]
|
| 189 |
+
|
| 190 |
+
videos.append({
|
| 191 |
+
"title": snippet["title"],
|
| 192 |
+
"videoId": video_id,
|
| 193 |
+
"thumbnail": snippet["thumbnails"].get("medium", {}).get("url", ""),
|
| 194 |
+
"description": snippet["description"][:150] + "..." if len(snippet["description"]) > 150 else snippet["description"],
|
| 195 |
+
"url": f"https://www.youtube.com/watch?v={video_id}",
|
| 196 |
+
"channelTitle": snippet["channelTitle"]
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
return videos
|
| 200 |
+
|
| 201 |
+
except HttpError as e:
|
| 202 |
+
logger.warning(f"YouTube API error for query '{query}': {e}")
|
| 203 |
+
# Return search URL fallback with generic placeholder
|
| 204 |
+
video_id = f"srch{abs(hash(query)) % 1000000}"
|
| 205 |
+
return [{
|
| 206 |
+
"title": query,
|
| 207 |
+
"videoId": video_id,
|
| 208 |
+
"thumbnail": f"https://via.placeholder.com/320x180/6b9175/ffffff?text={query[:30].replace(' ', '+')}",
|
| 209 |
+
"description": "Click to search YouTube for medical videos on this topic",
|
| 210 |
+
"url": f"https://www.youtube.com/results?search_query={query.replace(' ', '+')}",
|
| 211 |
+
"channelTitle": "YouTube Search"
|
| 212 |
+
}]
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logger.exception(f"Error searching YouTube: {e}")
|
| 216 |
+
return []
|
| 217 |
+
|
| 218 |
+
def _get_fallback_queries(self, organ: str, conditions: List[Dict]) -> List[str]:
|
| 219 |
+
"""Fallback queries when AI is unavailable"""
|
| 220 |
+
|
| 221 |
+
base_queries = {
|
| 222 |
+
"kidney": [
|
| 223 |
+
"how to improve kidney function naturally doctor explains",
|
| 224 |
+
"kidney disease prevention Mayo Clinic",
|
| 225 |
+
"best foods for kidney health medical review",
|
| 226 |
+
"kidney health exercises and lifestyle changes",
|
| 227 |
+
"understanding kidney function tests explained",
|
| 228 |
+
],
|
| 229 |
+
"heart": [
|
| 230 |
+
"heart health improvement doctor guide",
|
| 231 |
+
"cardiovascular disease prevention explained",
|
| 232 |
+
"best diet for heart health evidence based",
|
| 233 |
+
"heart disease risk factors medical review",
|
| 234 |
+
"improving heart health naturally Cleveland Clinic",
|
| 235 |
+
],
|
| 236 |
+
"liver": [
|
| 237 |
+
"liver health improvement natural methods",
|
| 238 |
+
"fatty liver disease treatment explained",
|
| 239 |
+
"liver detox what actually works doctor",
|
| 240 |
+
"liver function tests explained medical",
|
| 241 |
+
"best diet for liver health evidence based",
|
| 242 |
+
],
|
| 243 |
+
"lungs": [
|
| 244 |
+
"lung health improvement breathing exercises",
|
| 245 |
+
"respiratory health doctor explains",
|
| 246 |
+
"lung function improvement natural methods",
|
| 247 |
+
"breathing techniques for better lung health",
|
| 248 |
+
"pulmonary health prevention medical review",
|
| 249 |
+
],
|
| 250 |
+
"brain": [
|
| 251 |
+
"brain health improvement neuroplasticity",
|
| 252 |
+
"cognitive function optimization doctor",
|
| 253 |
+
"neurological health prevention methods",
|
| 254 |
+
"brain health diet and lifestyle medical",
|
| 255 |
+
"memory improvement techniques evidence based",
|
| 256 |
+
],
|
| 257 |
+
"blood": [
|
| 258 |
+
"blood health optimization complete guide",
|
| 259 |
+
"anemia treatment natural methods doctor",
|
| 260 |
+
"improving blood test results naturally",
|
| 261 |
+
"hematology basics medical explanation",
|
| 262 |
+
"blood health diet and supplements",
|
| 263 |
+
],
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
# Get base queries for organ
|
| 267 |
+
queries = base_queries.get(organ.lower(), [
|
| 268 |
+
f"{organ} health improvement doctor explains",
|
| 269 |
+
f"how to improve {organ} function naturally",
|
| 270 |
+
f"{organ} disease prevention medical review",
|
| 271 |
+
f"best diet for {organ} health evidence based",
|
| 272 |
+
f"{organ} health lifestyle changes doctor guide",
|
| 273 |
+
])
|
| 274 |
+
|
| 275 |
+
# Add condition-specific queries
|
| 276 |
+
if conditions:
|
| 277 |
+
for condition in conditions[:2]: # Top 2 conditions
|
| 278 |
+
condition_name = condition.get("name", "")
|
| 279 |
+
if condition_name:
|
| 280 |
+
queries.append(f"{condition_name} treatment explained doctor")
|
| 281 |
+
|
| 282 |
+
return queries[:5]
|
| 283 |
+
|
| 284 |
+
def _get_fallback_recommendations(self, organ: str, conditions: List[Dict]) -> List[Dict[str, str]]:
|
| 285 |
+
"""Static fallback when all APIs fail"""
|
| 286 |
+
|
| 287 |
+
queries = self._get_fallback_queries(organ, conditions)
|
| 288 |
+
|
| 289 |
+
return [{
|
| 290 |
+
"title": query,
|
| 291 |
+
"videoId": f"fallback_{i}_{abs(hash(query)) % 1000}",
|
| 292 |
+
"thumbnail": f"https://via.placeholder.com/320x180/6b9175/ffffff?text={query[:30].replace(' ', '+')}",
|
| 293 |
+
"description": "Click to search YouTube for medical videos on this topic",
|
| 294 |
+
"url": f"https://www.youtube.com/results?search_query={query.replace(' ', '+')}",
|
| 295 |
+
"channelTitle": "YouTube Search"
|
| 296 |
+
} for i, query in enumerate(queries)]
|
backend/requirements.txt
CHANGED
|
@@ -40,6 +40,9 @@ ollama>=0.1.0
|
|
| 40 |
google-generativeai>=0.4.0
|
| 41 |
openai>=1.0.0 # Used as OpenRouter SDK (OpenAI-compatible API)
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
# Local MedGemma via Hugging Face
|
| 44 |
# Keep transformers on the 4.x line; 5.x requires newer torch and breaks
|
| 45 |
# sentence-transformers import paths used by Mem0 huggingface embeddings.
|
|
|
|
| 40 |
google-generativeai>=0.4.0
|
| 41 |
openai>=1.0.0 # Used as OpenRouter SDK (OpenAI-compatible API)
|
| 42 |
|
| 43 |
+
# YouTube API for recommendations
|
| 44 |
+
google-api-python-client>=2.100.0
|
| 45 |
+
|
| 46 |
# Local MedGemma via Hugging Face
|
| 47 |
# Keep transformers on the 4.x line; 5.x requires newer torch and breaks
|
| 48 |
# sentence-transformers import paths used by Mem0 huggingface embeddings.
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenRouter API Key
|
| 2 |
+
# Used for generating intelligent YouTube video recommendations
|
| 3 |
+
# Get your key from: https://openrouter.ai/keys
|
| 4 |
+
VITE_OPENROUTER_API_KEY=sk-or-v1-your_key_here
|
| 5 |
+
|
| 6 |
+
# Note: No YouTube Data API key needed - we use YouTube search URLs directly
|
frontend/src/components/HealthGraph.css
CHANGED
|
@@ -268,19 +268,20 @@
|
|
| 268 |
transform-origin: center center;
|
| 269 |
}
|
| 270 |
|
| 271 |
-
/* SVG Styles */
|
| 272 |
.relationship-line {
|
| 273 |
-
stroke:
|
| 274 |
-
stroke-width: 2;
|
| 275 |
fill: none;
|
| 276 |
}
|
| 277 |
|
| 278 |
.relationship-label {
|
| 279 |
-
font-size:
|
| 280 |
-
fill: var(--dash-text-
|
| 281 |
text-anchor: middle;
|
| 282 |
pointer-events: none;
|
| 283 |
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
|
|
|
| 284 |
}
|
| 285 |
|
| 286 |
.node {
|
|
@@ -294,21 +295,23 @@
|
|
| 294 |
|
| 295 |
.node-circle {
|
| 296 |
stroke: white;
|
| 297 |
-
stroke-width:
|
| 298 |
-
filter: drop-shadow(0
|
| 299 |
transition: all var(--dash-transition-fast, 150ms);
|
| 300 |
}
|
| 301 |
|
| 302 |
.node:hover .node-circle {
|
| 303 |
-
filter: drop-shadow(0
|
|
|
|
| 304 |
}
|
| 305 |
|
| 306 |
.node-label {
|
| 307 |
-
font-size:
|
| 308 |
fill: white;
|
| 309 |
-
font-weight:
|
| 310 |
pointer-events: none;
|
| 311 |
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
|
|
|
| 312 |
}
|
| 313 |
|
| 314 |
/* Tooltip */
|
|
|
|
| 268 |
transform-origin: center center;
|
| 269 |
}
|
| 270 |
|
| 271 |
+
/* SVG Styles - Larger, More Readable Nodes */
|
| 272 |
.relationship-line {
|
| 273 |
+
stroke: rgba(107, 145, 117, 0.25);
|
| 274 |
+
stroke-width: 2.5;
|
| 275 |
fill: none;
|
| 276 |
}
|
| 277 |
|
| 278 |
.relationship-label {
|
| 279 |
+
font-size: 11px;
|
| 280 |
+
fill: var(--dash-text-secondary, #6b6b6b);
|
| 281 |
text-anchor: middle;
|
| 282 |
pointer-events: none;
|
| 283 |
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 284 |
+
font-weight: 500;
|
| 285 |
}
|
| 286 |
|
| 287 |
.node {
|
|
|
|
| 295 |
|
| 296 |
.node-circle {
|
| 297 |
stroke: white;
|
| 298 |
+
stroke-width: 4;
|
| 299 |
+
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.15));
|
| 300 |
transition: all var(--dash-transition-fast, 150ms);
|
| 301 |
}
|
| 302 |
|
| 303 |
.node:hover .node-circle {
|
| 304 |
+
filter: drop-shadow(0 5px 10px rgba(0, 0, 0, 0.25));
|
| 305 |
+
stroke-width: 5;
|
| 306 |
}
|
| 307 |
|
| 308 |
.node-label {
|
| 309 |
+
font-size: 12px;
|
| 310 |
fill: white;
|
| 311 |
+
font-weight: 700;
|
| 312 |
pointer-events: none;
|
| 313 |
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 314 |
+
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 315 |
}
|
| 316 |
|
| 317 |
/* Tooltip */
|
frontend/src/components/HealthGraph.tsx
CHANGED
|
@@ -524,7 +524,7 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 524 |
onMouseLeave={() => setHoveredNode(null)}
|
| 525 |
>
|
| 526 |
<circle
|
| 527 |
-
r={isHovered ?
|
| 528 |
fill={nodeColors[node.type] || nodeColors.entity}
|
| 529 |
className="node-circle"
|
| 530 |
/>
|
|
@@ -533,8 +533,8 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 533 |
textAnchor="middle"
|
| 534 |
className="node-label"
|
| 535 |
>
|
| 536 |
-
{node.name.length >
|
| 537 |
-
? node.name.slice(0,
|
| 538 |
: node.name}
|
| 539 |
</text>
|
| 540 |
</g>
|
|
|
|
| 524 |
onMouseLeave={() => setHoveredNode(null)}
|
| 525 |
>
|
| 526 |
<circle
|
| 527 |
+
r={isHovered ? 38 : 34}
|
| 528 |
fill={nodeColors[node.type] || nodeColors.entity}
|
| 529 |
className="node-circle"
|
| 530 |
/>
|
|
|
|
| 533 |
textAnchor="middle"
|
| 534 |
className="node-label"
|
| 535 |
>
|
| 536 |
+
{node.name.length > 14
|
| 537 |
+
? node.name.slice(0, 12) + '...'
|
| 538 |
: node.name}
|
| 539 |
</text>
|
| 540 |
</g>
|
frontend/src/components/MemoryDashboard.css
CHANGED
|
@@ -241,8 +241,9 @@
|
|
| 241 |
justify-content: space-between;
|
| 242 |
gap: var(--dash-spacing-md, 1rem);
|
| 243 |
padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-lg, 1.5rem);
|
| 244 |
-
background:
|
| 245 |
border: 1px solid var(--dash-border, #e5dfd5);
|
|
|
|
| 246 |
border-radius: var(--dash-radius-lg, 1rem);
|
| 247 |
margin-bottom: var(--dash-spacing-sm, 0.75rem);
|
| 248 |
transition: all var(--dash-transition-base, 250ms);
|
|
@@ -250,7 +251,9 @@
|
|
| 250 |
|
| 251 |
.memory-item:hover {
|
| 252 |
border-color: var(--dash-accent-light, #8fb199);
|
| 253 |
-
|
|
|
|
|
|
|
| 254 |
transform: translateY(-1px);
|
| 255 |
}
|
| 256 |
|
|
|
|
| 241 |
justify-content: space-between;
|
| 242 |
gap: var(--dash-spacing-md, 1rem);
|
| 243 |
padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-lg, 1.5rem);
|
| 244 |
+
background: linear-gradient(135deg, #ffffff 0%, #fafafa 100%);
|
| 245 |
border: 1px solid var(--dash-border, #e5dfd5);
|
| 246 |
+
border-left: 3px solid var(--dash-accent-light, #8fb199);
|
| 247 |
border-radius: var(--dash-radius-lg, 1rem);
|
| 248 |
margin-bottom: var(--dash-spacing-sm, 0.75rem);
|
| 249 |
transition: all var(--dash-transition-base, 250ms);
|
|
|
|
| 251 |
|
| 252 |
.memory-item:hover {
|
| 253 |
border-color: var(--dash-accent-light, #8fb199);
|
| 254 |
+
border-left-color: var(--dash-accent, #6b9175);
|
| 255 |
+
background: white;
|
| 256 |
+
box-shadow: 0 4px 12px rgba(107, 145, 117, 0.12);
|
| 257 |
transform: translateY(-1px);
|
| 258 |
}
|
| 259 |
|
frontend/src/components/dashboard/DashboardNavbar.css
CHANGED
|
@@ -73,18 +73,19 @@
|
|
| 73 |
.dashboard-nav {
|
| 74 |
display: flex;
|
| 75 |
align-items: center;
|
| 76 |
-
gap: var(--dash-spacing-
|
| 77 |
}
|
| 78 |
|
| 79 |
.dashboard-nav-link {
|
| 80 |
padding: var(--dash-spacing-sm) var(--dash-spacing-md);
|
| 81 |
-
font-size: 0.
|
| 82 |
font-weight: 500;
|
| 83 |
color: var(--dash-text-secondary);
|
| 84 |
text-decoration: none;
|
| 85 |
border-radius: var(--dash-radius-md);
|
| 86 |
transition: all var(--dash-transition-fast);
|
| 87 |
position: relative;
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
.dashboard-nav-link:hover {
|
|
|
|
| 73 |
.dashboard-nav {
|
| 74 |
display: flex;
|
| 75 |
align-items: center;
|
| 76 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 77 |
}
|
| 78 |
|
| 79 |
.dashboard-nav-link {
|
| 80 |
padding: var(--dash-spacing-sm) var(--dash-spacing-md);
|
| 81 |
+
font-size: 0.9rem;
|
| 82 |
font-weight: 500;
|
| 83 |
color: var(--dash-text-secondary);
|
| 84 |
text-decoration: none;
|
| 85 |
border-radius: var(--dash-radius-md);
|
| 86 |
transition: all var(--dash-transition-fast);
|
| 87 |
position: relative;
|
| 88 |
+
white-space: nowrap;
|
| 89 |
}
|
| 90 |
|
| 91 |
.dashboard-nav-link:hover {
|
frontend/src/components/dashboard/DashboardNavbar.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
-
import { Settings, LogOut, Home, LayoutDashboard,
|
| 5 |
import { logout } from '../../utils/auth';
|
| 6 |
import Logo from '../ui/Logo';
|
| 7 |
import './DashboardNavbar.css';
|
|
@@ -69,16 +69,6 @@ function DashboardNavbar({ userName = 'User', userStatus = '87% Healthy' }: Dash
|
|
| 69 |
|
| 70 |
{/* Actions */}
|
| 71 |
<div className="dashboard-actions">
|
| 72 |
-
{/* Notifications */}
|
| 73 |
-
<motion.button
|
| 74 |
-
className="dash-btn dash-btn-icon dash-focus-ring"
|
| 75 |
-
whileHover={{ scale: 1.05 }}
|
| 76 |
-
whileTap={{ scale: 0.95 }}
|
| 77 |
-
aria-label="Notifications"
|
| 78 |
-
>
|
| 79 |
-
<Bell size={20} />
|
| 80 |
-
</motion.button>
|
| 81 |
-
|
| 82 |
{/* User Menu */}
|
| 83 |
<div className="dashboard-user-menu">
|
| 84 |
<motion.button
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
+
import { Settings, LogOut, Home, LayoutDashboard, FileText, Activity, Sparkles, Pill, PhoneCall, Network } from 'lucide-react';
|
| 5 |
import { logout } from '../../utils/auth';
|
| 6 |
import Logo from '../ui/Logo';
|
| 7 |
import './DashboardNavbar.css';
|
|
|
|
| 69 |
|
| 70 |
{/* Actions */}
|
| 71 |
<div className="dashboard-actions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
{/* User Menu */}
|
| 73 |
<div className="dashboard-user-menu">
|
| 74 |
<motion.button
|
frontend/src/components/physics/ExplainabilityCard.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
/**
|
| 2 |
* ExplainabilityCard – deterministic scoring breakdown.
|
| 3 |
* Shows weights, normalised contributions, coverage/confidence.
|
| 4 |
-
*
|
| 5 |
*/
|
| 6 |
|
| 7 |
import React from 'react';
|
|
@@ -21,17 +21,17 @@ const ExplainabilityCard: React.FC<ExplainabilityCardProps> = ({
|
|
| 21 |
}) => {
|
| 22 |
if (!selectedOrgan || !organResult) {
|
| 23 |
return (
|
| 24 |
-
<div className="dash-card
|
| 25 |
<div className="dash-card-header">
|
| 26 |
-
<h3 className="dash-card-title">
|
| 27 |
<BarChart3 size={18} />
|
| 28 |
-
|
| 29 |
</h3>
|
| 30 |
</div>
|
| 31 |
-
<div className="dash-card-body">
|
| 32 |
-
<div className="
|
| 33 |
-
<Info size={
|
| 34 |
-
<p>Select an organ to see
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
</div>
|
|
@@ -41,63 +41,131 @@ const ExplainabilityCard: React.FC<ExplainabilityCardProps> = ({
|
|
| 41 |
const presentMetrics = organResult.contributions.filter((c) => c.value != null);
|
| 42 |
const totalWeight = organResult.contributions.reduce((s, c) => s + c.weight, 0);
|
| 43 |
const presentWeight = presentMetrics.reduce((s, c) => s + c.weight, 0);
|
|
|
|
| 44 |
|
| 45 |
return (
|
| 46 |
-
<div className="dash-card
|
|
|
|
| 47 |
<div className="dash-card-header">
|
| 48 |
-
<h3 className="dash-card-title">
|
| 49 |
<BarChart3 size={18} />
|
| 50 |
Why this score?
|
| 51 |
</h3>
|
| 52 |
-
<span className=
|
|
|
|
|
|
|
| 53 |
</div>
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
-
<div
|
| 63 |
-
<
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
-
<div
|
| 67 |
-
<
|
| 68 |
-
{
|
| 69 |
-
</
|
| 70 |
-
<
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
|
| 74 |
-
{/*
|
| 75 |
-
<div
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
<span>Metric</span>
|
| 78 |
-
<span>Value</span>
|
| 79 |
-
<span>Norm</span>
|
| 80 |
-
<span>Weight</span>
|
| 81 |
-
<span>Contrib</span>
|
| 82 |
</div>
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</div>
|
| 91 |
))}
|
| 92 |
</div>
|
| 93 |
|
| 94 |
-
{/* Formula
|
| 95 |
-
<div
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
Missing metrics are excluded — coverage reflects data completeness.
|
| 100 |
-
</
|
| 101 |
</div>
|
| 102 |
</div>
|
| 103 |
</div>
|
|
|
|
| 1 |
/**
|
| 2 |
* ExplainabilityCard – deterministic scoring breakdown.
|
| 3 |
* Shows weights, normalised contributions, coverage/confidence.
|
| 4 |
+
* Matches clean Tailwind design system.
|
| 5 |
*/
|
| 6 |
|
| 7 |
import React from 'react';
|
|
|
|
| 21 |
}) => {
|
| 22 |
if (!selectedOrgan || !organResult) {
|
| 23 |
return (
|
| 24 |
+
<div className="dash-card h-full flex flex-col">
|
| 25 |
<div className="dash-card-header">
|
| 26 |
+
<h3 className="dash-card-title flex items-center gap-2">
|
| 27 |
<BarChart3 size={18} />
|
| 28 |
+
Why this score?
|
| 29 |
</h3>
|
| 30 |
</div>
|
| 31 |
+
<div className="dash-card-body flex-1 flex items-center justify-center">
|
| 32 |
+
<div className="text-center" style={{ color: 'var(--dash-text-muted)' }}>
|
| 33 |
+
<Info size={48} style={{ margin: '0 auto', opacity: 0.3 }} />
|
| 34 |
+
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>Select an organ to see details</p>
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
</div>
|
|
|
|
| 41 |
const presentMetrics = organResult.contributions.filter((c) => c.value != null);
|
| 42 |
const totalWeight = organResult.contributions.reduce((s, c) => s + c.weight, 0);
|
| 43 |
const presentWeight = presentMetrics.reduce((s, c) => s + c.weight, 0);
|
| 44 |
+
const confidence = presentWeight > 0 ? Math.round((presentWeight / totalWeight) * 100) : 0;
|
| 45 |
|
| 46 |
return (
|
| 47 |
+
<div className="dash-card h-full flex flex-col">
|
| 48 |
+
{/* Header */}
|
| 49 |
<div className="dash-card-header">
|
| 50 |
+
<h3 className="dash-card-title flex items-center gap-2">
|
| 51 |
<BarChart3 size={18} />
|
| 52 |
Why this score?
|
| 53 |
</h3>
|
| 54 |
+
<span className={`dash-badge dash-badge-${organResult.status === 'Healthy' ? 'success' : organResult.status === 'Watch' ? 'warning' : 'danger'}`}>
|
| 55 |
+
{organLabel}
|
| 56 |
+
</span>
|
| 57 |
</div>
|
| 58 |
|
| 59 |
+
{/* Content */}
|
| 60 |
+
<div className="dash-card-body flex-1" style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
| 61 |
+
{/* Summary Stats */}
|
| 62 |
+
<div style={{
|
| 63 |
+
display: 'grid',
|
| 64 |
+
gridTemplateColumns: 'repeat(3, 1fr)',
|
| 65 |
+
gap: '1rem',
|
| 66 |
+
marginBottom: '1.5rem',
|
| 67 |
+
padding: '1rem',
|
| 68 |
+
background: 'var(--dash-accent-pale)',
|
| 69 |
+
borderRadius: 'var(--dash-radius-lg)',
|
| 70 |
+
}}>
|
| 71 |
+
<div style={{ textAlign: 'center' }}>
|
| 72 |
+
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--dash-success)' }}>
|
| 73 |
+
{Math.round(organResult.score)}
|
| 74 |
+
</div>
|
| 75 |
+
<div style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)', marginTop: '0.25rem' }}>Score</div>
|
| 76 |
</div>
|
| 77 |
+
<div style={{ textAlign: 'center' }}>
|
| 78 |
+
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--dash-info)' }}>
|
| 79 |
+
{Math.round(organResult.coverage * 100)}%
|
| 80 |
+
</div>
|
| 81 |
+
<div style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)', marginTop: '0.25rem' }}>Coverage</div>
|
| 82 |
</div>
|
| 83 |
+
<div style={{ textAlign: 'center' }}>
|
| 84 |
+
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--dash-accent)' }}>
|
| 85 |
+
{confidence}%
|
| 86 |
+
</div>
|
| 87 |
+
<div style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)', marginTop: '0.25rem' }}>Confidence</div>
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
+
{/* Metrics Table */}
|
| 92 |
+
<div style={{ marginTop: '1rem' }}>
|
| 93 |
+
{/* Header */}
|
| 94 |
+
<div style={{
|
| 95 |
+
display: 'grid',
|
| 96 |
+
gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr',
|
| 97 |
+
gap: '0.5rem',
|
| 98 |
+
padding: '0.5rem 0.75rem',
|
| 99 |
+
fontSize: '0.75rem',
|
| 100 |
+
fontWeight: 600,
|
| 101 |
+
color: 'var(--dash-text-muted)',
|
| 102 |
+
textTransform: 'uppercase',
|
| 103 |
+
borderBottom: '2px solid var(--dash-border)',
|
| 104 |
+
}}>
|
| 105 |
<span>Metric</span>
|
| 106 |
+
<span style={{ textAlign: 'right' }}>Value</span>
|
| 107 |
+
<span style={{ textAlign: 'right' }}>Norm</span>
|
| 108 |
+
<span style={{ textAlign: 'right' }}>Weight</span>
|
| 109 |
+
<span style={{ textAlign: 'right' }}>Contrib</span>
|
| 110 |
</div>
|
| 111 |
+
|
| 112 |
+
{/* Rows */}
|
| 113 |
+
{organResult.contributions.map((c, index) => (
|
| 114 |
+
<div
|
| 115 |
+
key={c.name}
|
| 116 |
+
style={{
|
| 117 |
+
display: 'grid',
|
| 118 |
+
gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr',
|
| 119 |
+
gap: '0.5rem',
|
| 120 |
+
padding: '0.625rem 0.75rem',
|
| 121 |
+
fontSize: '0.875rem',
|
| 122 |
+
background: c.value == null ? 'transparent' : (index % 2 === 0 ? 'var(--dash-surface)' : 'var(--dash-surface-hover)'),
|
| 123 |
+
borderRadius: 'var(--dash-radius-sm)',
|
| 124 |
+
color: c.value == null ? 'var(--dash-text-muted)' : 'var(--dash-text)',
|
| 125 |
+
transition: 'background 0.2s',
|
| 126 |
+
}}
|
| 127 |
+
onMouseEnter={(e) => {
|
| 128 |
+
if (c.value != null) e.currentTarget.style.background = 'var(--dash-accent-pale)';
|
| 129 |
+
}}
|
| 130 |
+
onMouseLeave={(e) => {
|
| 131 |
+
if (c.value != null) e.currentTarget.style.background = index % 2 === 0 ? 'var(--dash-surface)' : 'var(--dash-surface-hover)';
|
| 132 |
+
}}
|
| 133 |
+
>
|
| 134 |
+
<span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 135 |
+
{c.name}
|
| 136 |
+
</span>
|
| 137 |
+
<span style={{ textAlign: 'right', fontFamily: 'monospace' }}>
|
| 138 |
+
{c.value != null ? c.value.toString() : '—'}
|
| 139 |
+
</span>
|
| 140 |
+
<span style={{ textAlign: 'right', fontFamily: 'monospace' }}>
|
| 141 |
+
{c.normalised != null ? c.normalised.toFixed(2) : '—'}
|
| 142 |
+
</span>
|
| 143 |
+
<span style={{ textAlign: 'right', fontWeight: 600, color: 'var(--dash-info)' }}>
|
| 144 |
+
{(c.weight * 100).toFixed(0)}%
|
| 145 |
+
</span>
|
| 146 |
+
<span style={{ textAlign: 'right', fontWeight: 600, color: 'var(--dash-success)' }}>
|
| 147 |
+
{c.weighted != null ? (c.weighted * 100).toFixed(1) : '—'}
|
| 148 |
+
</span>
|
| 149 |
</div>
|
| 150 |
))}
|
| 151 |
</div>
|
| 152 |
|
| 153 |
+
{/* Formula Note */}
|
| 154 |
+
<div style={{
|
| 155 |
+
marginTop: '1.5rem',
|
| 156 |
+
padding: '1rem',
|
| 157 |
+
background: 'var(--dash-accent-pale)',
|
| 158 |
+
border: '1px solid var(--dash-accent-light)',
|
| 159 |
+
borderRadius: 'var(--dash-radius-lg)',
|
| 160 |
+
display: 'flex',
|
| 161 |
+
alignItems: 'flex-start',
|
| 162 |
+
gap: '0.75rem',
|
| 163 |
+
}}>
|
| 164 |
+
<Info size={16} style={{ color: 'var(--dash-accent)', flexShrink: 0, marginTop: '2px' }} />
|
| 165 |
+
<p style={{ fontSize: '0.75rem', color: 'var(--dash-text-secondary)', lineHeight: 1.6 }}>
|
| 166 |
+
<strong>Score Formula:</strong> Σ (weight<sub>i</sub> × normalised<sub>i</sub>) / Σ weight<sub>present</sub> × 100.
|
| 167 |
Missing metrics are excluded — coverage reflects data completeness.
|
| 168 |
+
</p>
|
| 169 |
</div>
|
| 170 |
</div>
|
| 171 |
</div>
|
frontend/src/components/physics/YouTubeRecommendationsCard.css
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* YouTubeRecommendationsCard styles
|
| 3 |
+
* Matches Lumea dashboard design tokens
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
.youtube-reco-card {
|
| 7 |
+
/* Card inherits dash-card base styles */
|
| 8 |
+
display: flex;
|
| 9 |
+
flex-direction: column;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.youtube-reco-card .dash-card-body {
|
| 13 |
+
flex: 1;
|
| 14 |
+
display: flex;
|
| 15 |
+
flex-direction: column;
|
| 16 |
+
min-height: 0;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.youtube-reco-organ-badge {
|
| 20 |
+
padding: 0.35rem 0.75rem;
|
| 21 |
+
background: linear-gradient(135deg, var(--sage-green, #6b9175) 0%, var(--sage-dark, #4a7c59) 100%);
|
| 22 |
+
color: white;
|
| 23 |
+
border-radius: 6px;
|
| 24 |
+
font-size: 0.8125rem;
|
| 25 |
+
font-weight: 600;
|
| 26 |
+
letter-spacing: 0.025em;
|
| 27 |
+
box-shadow: 0 2px 4px rgba(107, 145, 117, 0.2);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.youtube-reco-organ-label {
|
| 31 |
+
font-size: 0.85rem;
|
| 32 |
+
color: var(--text-secondary, #6b7280);
|
| 33 |
+
font-style: italic;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Empty state */
|
| 37 |
+
.youtube-reco-empty {
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: column;
|
| 40 |
+
align-items: center;
|
| 41 |
+
justify-content: center;
|
| 42 |
+
padding: 2.5rem 1rem;
|
| 43 |
+
text-align: center;
|
| 44 |
+
color: var(--text-muted, #9ca3af);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.youtube-reco-empty svg {
|
| 48 |
+
color: var(--sage-green, #6b9175);
|
| 49 |
+
margin-bottom: 1rem;
|
| 50 |
+
opacity: 0.5;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.youtube-reco-empty p {
|
| 54 |
+
font-size: 0.9rem;
|
| 55 |
+
margin: 0;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.youtube-reco-empty-subtitle {
|
| 59 |
+
font-size: 0.8125rem !important;
|
| 60 |
+
color: var(--text-muted, #9ca3af) !important;
|
| 61 |
+
margin-top: 0.5rem !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Loading state */
|
| 65 |
+
.youtube-reco-loading {
|
| 66 |
+
display: flex;
|
| 67 |
+
flex-direction: column;
|
| 68 |
+
align-items: center;
|
| 69 |
+
justify-content: center;
|
| 70 |
+
padding: 2.5rem 1rem;
|
| 71 |
+
text-align: center;
|
| 72 |
+
color: var(--text-secondary, #6b7280);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.youtube-reco-loading .spinner {
|
| 76 |
+
color: var(--sage-green, #6b9175);
|
| 77 |
+
margin-bottom: 1rem;
|
| 78 |
+
animation: spin 1s linear infinite;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@keyframes spin {
|
| 82 |
+
to { transform: rotate(360deg); }
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.youtube-reco-loading p {
|
| 86 |
+
font-size: 0.9rem;
|
| 87 |
+
margin: 0;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Error state */
|
| 91 |
+
.youtube-reco-error {
|
| 92 |
+
display: flex;
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
align-items: center;
|
| 95 |
+
justify-content: center;
|
| 96 |
+
padding: 2rem 1rem;
|
| 97 |
+
text-align: center;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.youtube-reco-error svg {
|
| 101 |
+
color: var(--danger-color, #ef4444);
|
| 102 |
+
margin-bottom: 1rem;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.youtube-reco-error p {
|
| 106 |
+
font-size: 0.9rem;
|
| 107 |
+
color: var(--text-secondary, #6b7280);
|
| 108 |
+
margin: 0 0 1rem 0;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.youtube-reco-retry {
|
| 112 |
+
padding: 0.5rem 1.25rem;
|
| 113 |
+
background: var(--sage-green, #6b9175);
|
| 114 |
+
color: white;
|
| 115 |
+
border: none;
|
| 116 |
+
border-radius: 6px;
|
| 117 |
+
font-size: 0.875rem;
|
| 118 |
+
font-weight: 500;
|
| 119 |
+
cursor: pointer;
|
| 120 |
+
transition: all 0.2s ease;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.youtube-reco-retry:hover {
|
| 124 |
+
background: var(--sage-dark, #4a7c59);
|
| 125 |
+
transform: translateY(-1px);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Video list */
|
| 129 |
+
.youtube-reco-list {
|
| 130 |
+
display: flex;
|
| 131 |
+
flex-direction: column;
|
| 132 |
+
gap: 1rem;
|
| 133 |
+
flex: 1;
|
| 134 |
+
overflow-y: auto;
|
| 135 |
+
overflow-x: hidden;
|
| 136 |
+
padding-right: 0.25rem;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Custom scrollbar */
|
| 140 |
+
.youtube-reco-list::-webkit-scrollbar {
|
| 141 |
+
width: 6px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.youtube-reco-list::-webkit-scrollbar-track {
|
| 145 |
+
background: rgba(107, 145, 117, 0.05);
|
| 146 |
+
border-radius: 3px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.youtube-reco-list::-webkit-scrollbar-thumb {
|
| 150 |
+
background: rgba(107, 145, 117, 0.2);
|
| 151 |
+
border-radius: 3px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.youtube-reco-list::-webkit-scrollbar-thumb:hover {
|
| 155 |
+
background: rgba(107, 145, 117, 0.3);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Video card */
|
| 159 |
+
.youtube-video-card {
|
| 160 |
+
display: flex;
|
| 161 |
+
gap: 0.875rem;
|
| 162 |
+
padding: 1rem;
|
| 163 |
+
background: linear-gradient(135deg, #ffffff 0%, #fafafa 100%);
|
| 164 |
+
border: 1px solid rgba(107, 145, 117, 0.12);
|
| 165 |
+
border-radius: 8px;
|
| 166 |
+
cursor: pointer;
|
| 167 |
+
transition: all 0.25s ease;
|
| 168 |
+
position: relative;
|
| 169 |
+
min-height: 110px;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.youtube-video-card:hover {
|
| 173 |
+
transform: translateY(-2px);
|
| 174 |
+
box-shadow: 0 4px 12px rgba(107, 145, 117, 0.15);
|
| 175 |
+
border-color: var(--sage-green, #6b9175);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* Video thumbnail */
|
| 179 |
+
.youtube-video-thumbnail {
|
| 180 |
+
flex-shrink: 0;
|
| 181 |
+
width: 180px;
|
| 182 |
+
height: 101px; /* 16:9 aspect ratio */
|
| 183 |
+
border-radius: 6px;
|
| 184 |
+
overflow: hidden;
|
| 185 |
+
background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 100%);
|
| 186 |
+
position: relative;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.youtube-video-thumbnail img {
|
| 190 |
+
width: 100%;
|
| 191 |
+
height: 100%;
|
| 192 |
+
object-fit: cover;
|
| 193 |
+
transition: transform 0.3s ease;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.youtube-video-card:hover .youtube-video-thumbnail img {
|
| 197 |
+
transform: scale(1.05);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Play overlay */
|
| 201 |
+
.youtube-video-play-overlay {
|
| 202 |
+
position: absolute;
|
| 203 |
+
top: 0;
|
| 204 |
+
left: 0;
|
| 205 |
+
right: 0;
|
| 206 |
+
bottom: 0;
|
| 207 |
+
display: flex;
|
| 208 |
+
align-items: center;
|
| 209 |
+
justify-content: center;
|
| 210 |
+
background: rgba(0, 0, 0, 0.3);
|
| 211 |
+
opacity: 0;
|
| 212 |
+
transition: opacity 0.25s ease;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.youtube-video-card:hover .youtube-video-play-overlay {
|
| 216 |
+
opacity: 1;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.youtube-video-play-overlay svg {
|
| 220 |
+
color: white;
|
| 221 |
+
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* Video info */
|
| 225 |
+
.youtube-video-info {
|
| 226 |
+
flex: 1;
|
| 227 |
+
display: flex;
|
| 228 |
+
flex-direction: column;
|
| 229 |
+
gap: 0.375rem;
|
| 230 |
+
min-width: 0; /* Enable text truncation */
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.youtube-video-title {
|
| 234 |
+
font-size: 0.9375rem;
|
| 235 |
+
font-weight: 600;
|
| 236 |
+
color: var(--text-primary, #111827);
|
| 237 |
+
margin: 0;
|
| 238 |
+
line-height: 1.35;
|
| 239 |
+
display: -webkit-box;
|
| 240 |
+
-webkit-line-clamp: 2;
|
| 241 |
+
-webkit-box-orient: vertical;
|
| 242 |
+
overflow: hidden;
|
| 243 |
+
transition: color 0.2s ease;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.youtube-video-card:hover .youtube-video-title {
|
| 247 |
+
color: var(--sage-green, #6b9175);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.youtube-video-channel {
|
| 251 |
+
font-size: 0.8125rem;
|
| 252 |
+
color: var(--text-secondary, #6b7280);
|
| 253 |
+
margin: 0;
|
| 254 |
+
font-weight: 500;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.youtube-video-description {
|
| 258 |
+
font-size: 0.8125rem;
|
| 259 |
+
color: var(--text-muted, #9ca3af);
|
| 260 |
+
margin: 0;
|
| 261 |
+
line-height: 1.4;
|
| 262 |
+
display: -webkit-box;
|
| 263 |
+
-webkit-line-clamp: 2;
|
| 264 |
+
-webkit-box-orient: vertical;
|
| 265 |
+
overflow: hidden;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* Link icon */
|
| 269 |
+
.youtube-video-link-icon {
|
| 270 |
+
flex-shrink: 0;
|
| 271 |
+
align-self: flex-start;
|
| 272 |
+
margin-top: 0.25rem;
|
| 273 |
+
color: var(--text-muted, #9ca3af);
|
| 274 |
+
transition: color 0.2s ease;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.youtube-video-card:hover .youtube-video-link-icon {
|
| 278 |
+
color: var(--sage-green, #6b9175);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/* Responsive adjustments */
|
| 282 |
+
@media (max-width: 768px) {
|
| 283 |
+
.youtube-video-card {
|
| 284 |
+
flex-direction: column;
|
| 285 |
+
min-height: auto;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.youtube-video-thumbnail {
|
| 289 |
+
width: 100%;
|
| 290 |
+
height: 200px;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.youtube-video-link-icon {
|
| 294 |
+
position: absolute;
|
| 295 |
+
top: 1rem;
|
| 296 |
+
right: 1rem;
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
@media (max-width: 1200px) {
|
| 301 |
+
.youtube-video-thumbnail {
|
| 302 |
+
width: 140px;
|
| 303 |
+
height: 79px;
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* Compact view for smaller cards */
|
| 308 |
+
.youtube-reco-card.compact .youtube-video-thumbnail {
|
| 309 |
+
width: 120px;
|
| 310 |
+
height: 68px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.youtube-reco-card.compact .youtube-video-title {
|
| 314 |
+
font-size: 0.875rem;
|
| 315 |
+
-webkit-line-clamp: 1;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.youtube-reco-card.compact .youtube-video-description {
|
| 319 |
+
display: none;
|
| 320 |
+
}
|
frontend/src/components/physics/YouTubeRecommendationsCard.tsx
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* YouTubeRecommendationsCard – Smart video recommendations for organ health
|
| 3 |
+
*
|
| 4 |
+
* Uses OpenRouter AI to generate video recommendations (no YouTube API needed)
|
| 5 |
+
* FIXED: Stable fetch with caching, no infinite loops
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 9 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 10 |
+
import { Youtube, ExternalLink, Loader2, AlertCircle, PlayCircle, Clock } from 'lucide-react';
|
| 11 |
+
import type { OrganResult } from '../../services/physicsApi';
|
| 12 |
+
import { generateVideoRecommendations, type AbnormalMetric, type VideoRecommendation } from '../../services/openRouterService';
|
| 13 |
+
|
| 14 |
+
interface YouTubeRecommendationsCardProps {
|
| 15 |
+
selectedOrgan: string | null;
|
| 16 |
+
organResult: OrganResult | null;
|
| 17 |
+
currentMetrics: Record<string, number>;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const ORGAN_LABELS: Record<string, string> = {
|
| 21 |
+
kidney: 'Kidney',
|
| 22 |
+
heart: 'Heart',
|
| 23 |
+
liver: 'Liver',
|
| 24 |
+
lungs: 'Lungs',
|
| 25 |
+
brain: 'Brain',
|
| 26 |
+
blood: 'Blood',
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
// Reference ranges for metrics
|
| 30 |
+
const METRIC_RANGES: Record<string, { min: number; max: number }> = {
|
| 31 |
+
creatinine: { min: 0.6, max: 1.2 },
|
| 32 |
+
urea: { min: 7, max: 20 },
|
| 33 |
+
alt: { min: 7, max: 56 },
|
| 34 |
+
ast: { min: 10, max: 40 },
|
| 35 |
+
bilirubin_total: { min: 0.1, max: 1.2 },
|
| 36 |
+
systolic_bp: { min: 90, max: 120 },
|
| 37 |
+
diastolic_bp: { min: 60, max: 80 },
|
| 38 |
+
heart_rate: { min: 60, max: 100 },
|
| 39 |
+
glucose: { min: 70, max: 100 },
|
| 40 |
+
spo2: { min: 95, max: 100 },
|
| 41 |
+
sodium: { min: 136, max: 145 },
|
| 42 |
+
potassium: { min: 3.5, max: 5.0 },
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const YouTubeRecommendationsCard: React.FC<YouTubeRecommendationsCardProps> = ({
|
| 46 |
+
selectedOrgan,
|
| 47 |
+
organResult,
|
| 48 |
+
currentMetrics,
|
| 49 |
+
}) => {
|
| 50 |
+
const [videos, setVideos] = useState<VideoRecommendation[]>([]);
|
| 51 |
+
const [loading, setLoading] = useState(false);
|
| 52 |
+
const [error, setError] = useState<string | null>(null);
|
| 53 |
+
|
| 54 |
+
// Cache videos per organ to avoid refetching
|
| 55 |
+
const cacheRef = useRef<Map<string, VideoRecommendation[]>>(new Map());
|
| 56 |
+
|
| 57 |
+
// Track current request to ignore stale responses
|
| 58 |
+
const requestIdRef = useRef(0);
|
| 59 |
+
|
| 60 |
+
// AbortController for cancelling requests
|
| 61 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
| 62 |
+
|
| 63 |
+
// Calculate abnormal metrics
|
| 64 |
+
const getAbnormalMetrics = (metrics: Record<string, number>): AbnormalMetric[] => {
|
| 65 |
+
const abnormal: AbnormalMetric[] = [];
|
| 66 |
+
|
| 67 |
+
for (const [metricKey, value] of Object.entries(metrics)) {
|
| 68 |
+
const range = METRIC_RANGES[metricKey];
|
| 69 |
+
if (!range || typeof value !== 'number') continue;
|
| 70 |
+
|
| 71 |
+
if (value < range.min) {
|
| 72 |
+
abnormal.push({
|
| 73 |
+
name: metricKey,
|
| 74 |
+
value,
|
| 75 |
+
normalRange: `${range.min}-${range.max}`,
|
| 76 |
+
status: 'low',
|
| 77 |
+
});
|
| 78 |
+
} else if (value > range.max) {
|
| 79 |
+
abnormal.push({
|
| 80 |
+
name: metricKey,
|
| 81 |
+
value,
|
| 82 |
+
normalRange: `${range.min}-${range.max}`,
|
| 83 |
+
status: 'high',
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return abnormal;
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
// Clear state if no organ selected
|
| 93 |
+
if (!selectedOrgan || !organResult) {
|
| 94 |
+
setVideos([]);
|
| 95 |
+
setLoading(false);
|
| 96 |
+
setError(null);
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Check cache first
|
| 101 |
+
const cached = cacheRef.current.get(selectedOrgan);
|
| 102 |
+
if (cached) {
|
| 103 |
+
setVideos(cached);
|
| 104 |
+
setLoading(false);
|
| 105 |
+
setError(null);
|
| 106 |
+
return;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Abort previous request if still running
|
| 110 |
+
if (abortControllerRef.current) {
|
| 111 |
+
abortControllerRef.current.abort();
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Start new fetch
|
| 115 |
+
const abortController = new AbortController();
|
| 116 |
+
abortControllerRef.current = abortController;
|
| 117 |
+
|
| 118 |
+
const currentRequestId = ++requestIdRef.current;
|
| 119 |
+
|
| 120 |
+
const fetchRecommendations = async () => {
|
| 121 |
+
setLoading(true);
|
| 122 |
+
setError(null);
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
const abnormalMetrics = getAbnormalMetrics(currentMetrics);
|
| 126 |
+
|
| 127 |
+
// Generate video recommendations using OpenRouter AI
|
| 128 |
+
const recommendations = await generateVideoRecommendations(
|
| 129 |
+
ORGAN_LABELS[selectedOrgan] || selectedOrgan,
|
| 130 |
+
abnormalMetrics
|
| 131 |
+
);
|
| 132 |
+
|
| 133 |
+
// Only update if this is still the latest request
|
| 134 |
+
if (currentRequestId === requestIdRef.current && !abortController.signal.aborted) {
|
| 135 |
+
setVideos(recommendations);
|
| 136 |
+
// Cache the results
|
| 137 |
+
cacheRef.current.set(selectedOrgan, recommendations);
|
| 138 |
+
setError(null);
|
| 139 |
+
}
|
| 140 |
+
} catch (err: any) {
|
| 141 |
+
// Only show error if request wasn't aborted
|
| 142 |
+
if (!abortController.signal.aborted && currentRequestId === requestIdRef.current) {
|
| 143 |
+
console.error('Error fetching recommendations:', err);
|
| 144 |
+
setError(err.message || 'Failed to load recommendations');
|
| 145 |
+
setVideos([]);
|
| 146 |
+
}
|
| 147 |
+
} finally {
|
| 148 |
+
// Only clear loading if this is still the latest request
|
| 149 |
+
if (currentRequestId === requestIdRef.current && !abortController.signal.aborted) {
|
| 150 |
+
setLoading(false);
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
fetchRecommendations();
|
| 156 |
+
|
| 157 |
+
// Cleanup: abort request on unmount or organ change
|
| 158 |
+
return () => {
|
| 159 |
+
abortController.abort();
|
| 160 |
+
};
|
| 161 |
+
}, [selectedOrgan]); // ONLY depend on selectedOrgan, not currentMetrics!
|
| 162 |
+
|
| 163 |
+
const handleRetry = () => {
|
| 164 |
+
// Clear cache for this organ and refetch
|
| 165 |
+
if (selectedOrgan) {
|
| 166 |
+
cacheRef.current.delete(selectedOrgan);
|
| 167 |
+
// Trigger refetch by incrementing request ID
|
| 168 |
+
requestIdRef.current++;
|
| 169 |
+
|
| 170 |
+
const fetchRecommendations = async () => {
|
| 171 |
+
if (!selectedOrgan || !organResult) return;
|
| 172 |
+
|
| 173 |
+
setLoading(true);
|
| 174 |
+
setError(null);
|
| 175 |
+
|
| 176 |
+
try {
|
| 177 |
+
const abnormalMetrics = getAbnormalMetrics(currentMetrics);
|
| 178 |
+
const recommendations = await generateVideoRecommendations(
|
| 179 |
+
ORGAN_LABELS[selectedOrgan] || selectedOrgan,
|
| 180 |
+
abnormalMetrics
|
| 181 |
+
);
|
| 182 |
+
|
| 183 |
+
setVideos(recommendations);
|
| 184 |
+
cacheRef.current.set(selectedOrgan, recommendations);
|
| 185 |
+
setError(null);
|
| 186 |
+
} catch (err: any) {
|
| 187 |
+
console.error('Error fetching recommendations:', err);
|
| 188 |
+
setError(err.message || 'Failed to load recommendations');
|
| 189 |
+
} finally {
|
| 190 |
+
setLoading(false);
|
| 191 |
+
}
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
fetchRecommendations();
|
| 195 |
+
}
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const openVideo = (url: string) => {
|
| 199 |
+
window.open(url, '_blank', 'noopener,noreferrer');
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
if (!selectedOrgan || !organResult) {
|
| 203 |
+
return (
|
| 204 |
+
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm overflow-hidden flex flex-col h-full">
|
| 205 |
+
<div className="p-6 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-teal-50">
|
| 206 |
+
<div className="flex items-center justify-between">
|
| 207 |
+
<div className="flex items-center gap-3">
|
| 208 |
+
<Youtube className="text-emerald-600" size={24} />
|
| 209 |
+
<h3 className="text-lg font-semibold text-gray-900">Improve Your Health</h3>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
<div className="flex-1 flex items-center justify-center p-8">
|
| 214 |
+
<div className="text-center text-gray-500 space-y-2">
|
| 215 |
+
<Youtube size={48} className="mx-auto opacity-30" />
|
| 216 |
+
<p className="text-sm font-medium">All organs performing well!</p>
|
| 217 |
+
<p className="text-xs text-gray-400">No specific recommendations needed</p>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return (
|
| 225 |
+
<div className="dash-card h-full flex flex-col">
|
| 226 |
+
{/* Header */}
|
| 227 |
+
<div className="dash-card-header">
|
| 228 |
+
<h3 className="dash-card-title flex items-center gap-2">
|
| 229 |
+
<Youtube size={18} />
|
| 230 |
+
Improve Your Health
|
| 231 |
+
</h3>
|
| 232 |
+
<span className={`dash-badge dash-badge-${organResult.status === 'Healthy' ? 'success' : organResult.status === 'Watch' ? 'warning' : 'danger'}`}>
|
| 233 |
+
{ORGAN_LABELS[selectedOrgan]}
|
| 234 |
+
</span>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* Content */}
|
| 238 |
+
<div className="dash-card-body flex-1" style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
| 239 |
+
<AnimatePresence mode="wait">
|
| 240 |
+
{loading && (
|
| 241 |
+
<motion.div
|
| 242 |
+
key="loading"
|
| 243 |
+
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 0' }}
|
| 244 |
+
initial={{ opacity: 0 }}
|
| 245 |
+
animate={{ opacity: 1 }}
|
| 246 |
+
exit={{ opacity: 0 }}
|
| 247 |
+
>
|
| 248 |
+
<Loader2 size={32} className="animate-spin" style={{ color: 'var(--dash-accent)' }} />
|
| 249 |
+
<p style={{ fontSize: '0.875rem', color: 'var(--dash-text-secondary)', marginTop: '0.75rem' }}>Finding best videos for you...</p>
|
| 250 |
+
</motion.div>
|
| 251 |
+
)}
|
| 252 |
+
|
| 253 |
+
{error && !loading && (
|
| 254 |
+
<motion.div
|
| 255 |
+
key="error"
|
| 256 |
+
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 0', textAlign: 'center' }}
|
| 257 |
+
initial={{ opacity: 0 }}
|
| 258 |
+
animate={{ opacity: 1 }}
|
| 259 |
+
exit={{ opacity: 0 }}
|
| 260 |
+
>
|
| 261 |
+
<AlertCircle size={32} style={{ color: 'var(--dash-danger)' }} />
|
| 262 |
+
<p style={{ fontSize: '0.875rem', color: 'var(--dash-text-secondary)', margin: '0.75rem 0' }}>{error}</p>
|
| 263 |
+
<button
|
| 264 |
+
onClick={handleRetry}
|
| 265 |
+
className="dash-btn dash-btn-primary"
|
| 266 |
+
>
|
| 267 |
+
Try Again
|
| 268 |
+
</button>
|
| 269 |
+
</motion.div>
|
| 270 |
+
)}
|
| 271 |
+
|
| 272 |
+
{!loading && !error && videos.length > 0 && (
|
| 273 |
+
<motion.div
|
| 274 |
+
key="videos"
|
| 275 |
+
className="space-y-4"
|
| 276 |
+
initial={{ opacity: 0 }}
|
| 277 |
+
animate={{ opacity: 1 }}
|
| 278 |
+
exit={{ opacity: 0 }}
|
| 279 |
+
>
|
| 280 |
+
{videos.map((video, index) => (
|
| 281 |
+
<motion.div
|
| 282 |
+
key={`${video.title}-${index}`}
|
| 283 |
+
style={{
|
| 284 |
+
background: 'var(--dash-surface)',
|
| 285 |
+
border: '1px solid var(--dash-border-light)',
|
| 286 |
+
borderRadius: 'var(--dash-radius-lg)',
|
| 287 |
+
padding: '1rem',
|
| 288 |
+
marginBottom: '0.75rem',
|
| 289 |
+
cursor: 'pointer',
|
| 290 |
+
transition: 'all 0.2s ease',
|
| 291 |
+
display: 'flex',
|
| 292 |
+
gap: '1rem',
|
| 293 |
+
}}
|
| 294 |
+
initial={{ opacity: 0, y: 10 }}
|
| 295 |
+
animate={{ opacity: 1, y: 0 }}
|
| 296 |
+
transition={{ delay: index * 0.1 }}
|
| 297 |
+
onClick={() => openVideo(video.url)}
|
| 298 |
+
onMouseEnter={(e) => {
|
| 299 |
+
e.currentTarget.style.border = '1px solid var(--dash-accent-light)';
|
| 300 |
+
e.currentTarget.style.boxShadow = 'var(--dash-shadow-md)';
|
| 301 |
+
}}
|
| 302 |
+
onMouseLeave={(e) => {
|
| 303 |
+
e.currentTarget.style.border = '1px solid var(--dash-border-light)';
|
| 304 |
+
e.currentTarget.style.boxShadow = 'none';
|
| 305 |
+
}}
|
| 306 |
+
>
|
| 307 |
+
{/* Thumbnail */}
|
| 308 |
+
<div style={{
|
| 309 |
+
flexShrink: 0,
|
| 310 |
+
width: '120px',
|
| 311 |
+
height: '80px',
|
| 312 |
+
background: 'linear-gradient(135deg, var(--dash-accent-pale), var(--dash-accent-light))',
|
| 313 |
+
borderRadius: 'var(--dash-radius-md)',
|
| 314 |
+
display: 'flex',
|
| 315 |
+
alignItems: 'center',
|
| 316 |
+
justifyContent: 'center',
|
| 317 |
+
position: 'relative',
|
| 318 |
+
overflow: 'hidden',
|
| 319 |
+
}}>
|
| 320 |
+
<Youtube size={32} style={{ color: 'var(--dash-accent)', opacity: 0.5 }} />
|
| 321 |
+
<div style={{
|
| 322 |
+
position: 'absolute',
|
| 323 |
+
inset: 0,
|
| 324 |
+
display: 'flex',
|
| 325 |
+
alignItems: 'center',
|
| 326 |
+
justifyContent: 'center',
|
| 327 |
+
transition: 'opacity 0.2s',
|
| 328 |
+
}}>
|
| 329 |
+
<PlayCircle size={32} style={{ color: 'white', opacity: 0.8 }} />
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
|
| 333 |
+
{/* Video Info */}
|
| 334 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 335 |
+
<h4 style={{
|
| 336 |
+
fontSize: '0.875rem',
|
| 337 |
+
fontWeight: 600,
|
| 338 |
+
color: 'var(--dash-text)',
|
| 339 |
+
marginBottom: '0.25rem',
|
| 340 |
+
lineHeight: 1.3,
|
| 341 |
+
display: '-webkit-box',
|
| 342 |
+
WebkitLineClamp: 2,
|
| 343 |
+
WebkitBoxOrient: 'vertical',
|
| 344 |
+
overflow: 'hidden',
|
| 345 |
+
}}>
|
| 346 |
+
{video.title}
|
| 347 |
+
</h4>
|
| 348 |
+
<p style={{
|
| 349 |
+
fontSize: '0.75rem',
|
| 350 |
+
color: 'var(--dash-text-secondary)',
|
| 351 |
+
marginBottom: '0.5rem',
|
| 352 |
+
display: '-webkit-box',
|
| 353 |
+
WebkitLineClamp: 2,
|
| 354 |
+
WebkitBoxOrient: 'vertical',
|
| 355 |
+
overflow: 'hidden',
|
| 356 |
+
}}>
|
| 357 |
+
{video.description}
|
| 358 |
+
</p>
|
| 359 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: 'var(--dash-text-muted)' }}>
|
| 360 |
+
<Clock size={12} />
|
| 361 |
+
<span>{video.duration}</span>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
{/* External Link Icon */}
|
| 366 |
+
<div style={{ flexShrink: 0 }}>
|
| 367 |
+
<ExternalLink size={16} style={{ color: 'var(--dash-text-muted)' }} />
|
| 368 |
+
</div>
|
| 369 |
+
</motion.div>
|
| 370 |
+
))}
|
| 371 |
+
</motion.div>
|
| 372 |
+
)}
|
| 373 |
+
|
| 374 |
+
{!loading && !error && videos.length === 0 && (
|
| 375 |
+
<motion.div
|
| 376 |
+
key="empty"
|
| 377 |
+
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 0', textAlign: 'center' }}
|
| 378 |
+
initial={{ opacity: 0 }}
|
| 379 |
+
animate={{ opacity: 1 }}
|
| 380 |
+
exit={{ opacity: 0 }}
|
| 381 |
+
>
|
| 382 |
+
<Youtube size={48} style={{ color: 'var(--dash-border)', opacity: 0.5 }} />
|
| 383 |
+
<p style={{ fontSize: '0.875rem', color: 'var(--dash-text-muted)', marginTop: '0.5rem' }}>No recommendations available</p>
|
| 384 |
+
</motion.div>
|
| 385 |
+
)}
|
| 386 |
+
</AnimatePresence>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
);
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
export default YouTubeRecommendationsCard;
|
frontend/src/components/ui/PdfPreview.css
CHANGED
|
@@ -2,9 +2,12 @@
|
|
| 2 |
position: relative;
|
| 3 |
width: 100%;
|
| 4 |
height: 100%;
|
|
|
|
| 5 |
background: #f5f5f5;
|
| 6 |
border-radius: 0.5rem;
|
| 7 |
-
overflow:
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
.pdf-preview-iframe {
|
|
|
|
| 2 |
position: relative;
|
| 3 |
width: 100%;
|
| 4 |
height: 100%;
|
| 5 |
+
min-height: 0;
|
| 6 |
background: #f5f5f5;
|
| 7 |
border-radius: 0.5rem;
|
| 8 |
+
overflow: auto;
|
| 9 |
+
display: flex;
|
| 10 |
+
flex-direction: column;
|
| 11 |
}
|
| 12 |
|
| 13 |
.pdf-preview-iframe {
|
frontend/src/pages/Features.css
CHANGED
|
@@ -1,74 +1,73 @@
|
|
| 1 |
-
/* Features Page Styles */
|
| 2 |
|
| 3 |
.features-page {
|
| 4 |
min-height: 100vh;
|
| 5 |
-
background:
|
| 6 |
position: relative;
|
| 7 |
overflow-x: hidden;
|
| 8 |
}
|
| 9 |
|
| 10 |
-
/* Animated Background */
|
| 11 |
.features-background {
|
| 12 |
position: fixed;
|
| 13 |
inset: 0;
|
| 14 |
z-index: 0;
|
| 15 |
overflow: hidden;
|
| 16 |
pointer-events: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
.features-bg-blob {
|
| 20 |
position: absolute;
|
| 21 |
border-radius: 50%;
|
| 22 |
-
filter: blur(
|
| 23 |
-
opacity: 0.
|
| 24 |
-
animation: blobFloat
|
| 25 |
}
|
| 26 |
|
| 27 |
.features-bg-blob-1 {
|
| 28 |
-
width:
|
| 29 |
-
height:
|
| 30 |
-
background:
|
| 31 |
-
top: -
|
| 32 |
-
right: -
|
| 33 |
animation-delay: 0s;
|
| 34 |
}
|
| 35 |
|
| 36 |
.features-bg-blob-2 {
|
| 37 |
-
width:
|
| 38 |
-
height:
|
| 39 |
-
background:
|
| 40 |
-
bottom: -
|
| 41 |
-
left: -
|
| 42 |
-
animation-delay: -
|
| 43 |
}
|
| 44 |
|
| 45 |
.features-bg-blob-3 {
|
| 46 |
-
width:
|
| 47 |
-
height:
|
| 48 |
-
background:
|
| 49 |
-
top:
|
| 50 |
left: 50%;
|
| 51 |
transform: translate(-50%, -50%);
|
| 52 |
-
animation-delay: -
|
| 53 |
}
|
| 54 |
|
| 55 |
@keyframes blobFloat {
|
| 56 |
-
|
| 57 |
-
0%,
|
| 58 |
-
100% {
|
| 59 |
transform: translate(0, 0) scale(1);
|
| 60 |
}
|
| 61 |
-
|
| 62 |
25% {
|
| 63 |
-
transform: translate(
|
| 64 |
}
|
| 65 |
-
|
| 66 |
50% {
|
| 67 |
-
transform: translate(-
|
| 68 |
}
|
| 69 |
-
|
| 70 |
75% {
|
| 71 |
-
transform: translate(
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
|
@@ -76,12 +75,13 @@
|
|
| 76 |
.features-content {
|
| 77 |
position: relative;
|
| 78 |
z-index: 1;
|
| 79 |
-
padding:
|
| 80 |
}
|
| 81 |
|
| 82 |
.features-container {
|
| 83 |
-
max-width:
|
| 84 |
margin: 0 auto;
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
.features-loading {
|
|
@@ -97,109 +97,139 @@
|
|
| 97 |
/* Hero Section */
|
| 98 |
.features-hero {
|
| 99 |
text-align: center;
|
| 100 |
-
padding:
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
.hero-badge {
|
| 104 |
display: inline-flex;
|
| 105 |
align-items: center;
|
| 106 |
gap: 8px;
|
| 107 |
-
padding: 8px
|
| 108 |
-
background: rgba(
|
| 109 |
-
|
| 110 |
-
border
|
| 111 |
-
|
|
|
|
| 112 |
font-size: 13px;
|
| 113 |
-
font-weight:
|
| 114 |
margin-bottom: 24px;
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
.features-hero h1 {
|
| 118 |
-
font-size: 3.
|
| 119 |
font-weight: 700;
|
| 120 |
-
color: var(--dash-text);
|
| 121 |
margin: 0 0 20px;
|
| 122 |
-
line-height: 1.
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
.gradient-text {
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
-webkit-text-fill-color: transparent;
|
| 129 |
-
background-clip: text;
|
| 130 |
}
|
| 131 |
|
| 132 |
.hero-description {
|
| 133 |
-
font-size: 1.
|
| 134 |
-
color: var(--dash-text-
|
| 135 |
-
max-width:
|
| 136 |
-
margin: 0 auto
|
| 137 |
line-height: 1.7;
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
-
/* Hero Features Grid */
|
| 141 |
.hero-features {
|
| 142 |
display: grid;
|
| 143 |
-
grid-template-columns: repeat(auto-fit, minmax(
|
| 144 |
gap: 24px;
|
| 145 |
-
max-width:
|
| 146 |
-
margin: 0 auto
|
| 147 |
text-align: left;
|
| 148 |
}
|
| 149 |
|
| 150 |
.hero-feature {
|
| 151 |
display: flex;
|
| 152 |
-
gap:
|
| 153 |
-
padding:
|
| 154 |
-
background:
|
| 155 |
-
|
| 156 |
-
border
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
.hero-feature:hover {
|
| 161 |
-
transform: translateY(-
|
| 162 |
-
border-color:
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
.hero-feature-icon {
|
| 167 |
-
width:
|
| 168 |
-
height:
|
| 169 |
-
border-radius:
|
| 170 |
display: flex;
|
| 171 |
align-items: center;
|
| 172 |
justify-content: center;
|
| 173 |
flex-shrink: 0;
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
.hero-feature-icon.memory {
|
| 177 |
-
background: rgba(
|
| 178 |
-
color: #
|
| 179 |
}
|
| 180 |
|
| 181 |
.hero-feature-icon.graph {
|
| 182 |
-
background: rgba(122, 156, 198, 0.
|
| 183 |
-
color: var(--dash-info);
|
| 184 |
}
|
| 185 |
|
| 186 |
.hero-feature-icon.ai {
|
| 187 |
-
background: rgba(
|
| 188 |
-
color: var(--dash-accent);
|
| 189 |
}
|
| 190 |
|
| 191 |
.hero-feature-content h4 {
|
| 192 |
-
margin: 0 0
|
| 193 |
-
font-size:
|
| 194 |
font-weight: 600;
|
| 195 |
-
color: var(--dash-text);
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
.hero-feature-content p {
|
| 199 |
margin: 0;
|
| 200 |
-
font-size:
|
| 201 |
-
color: var(--dash-text-
|
| 202 |
-
line-height: 1.
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
.scroll-indicator {
|
|
@@ -207,23 +237,43 @@
|
|
| 207 |
opacity: 0.5;
|
| 208 |
}
|
| 209 |
|
| 210 |
-
/* Flow Diagram */
|
| 211 |
.features-flow {
|
| 212 |
-
padding: 48px
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
|
| 216 |
.features-flow h2 {
|
| 217 |
-
font-size: 1.
|
| 218 |
-
color: var(--dash-text);
|
| 219 |
-
margin: 0 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
}
|
| 221 |
|
| 222 |
.flow-diagram {
|
| 223 |
display: flex;
|
| 224 |
align-items: center;
|
| 225 |
justify-content: center;
|
| 226 |
-
gap:
|
| 227 |
flex-wrap: wrap;
|
| 228 |
}
|
| 229 |
|
|
@@ -231,60 +281,68 @@
|
|
| 231 |
display: flex;
|
| 232 |
flex-direction: column;
|
| 233 |
align-items: center;
|
| 234 |
-
gap:
|
| 235 |
}
|
| 236 |
|
| 237 |
.flow-icon {
|
| 238 |
-
width:
|
| 239 |
-
height:
|
| 240 |
-
border-radius:
|
| 241 |
-
background:
|
| 242 |
-
|
|
|
|
| 243 |
display: flex;
|
| 244 |
align-items: center;
|
| 245 |
justify-content: center;
|
| 246 |
-
color: var(--dash-text);
|
| 247 |
-
transition: all
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
.flow-icon.memory {
|
| 251 |
-
border-color:
|
| 252 |
-
color: #
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
.flow-icon.graph {
|
| 256 |
-
border-color:
|
| 257 |
-
color: var(--dash-info);
|
|
|
|
| 258 |
}
|
| 259 |
|
| 260 |
.flow-icon.ai {
|
| 261 |
-
border-color:
|
| 262 |
-
color: var(--dash-accent);
|
|
|
|
| 263 |
}
|
| 264 |
|
| 265 |
.flow-step span {
|
| 266 |
-
font-size:
|
| 267 |
-
color: var(--dash-text-
|
| 268 |
-
font-weight:
|
|
|
|
| 269 |
}
|
| 270 |
|
| 271 |
.flow-arrow {
|
| 272 |
-
color: var(--dash-
|
| 273 |
-
opacity: 0.
|
|
|
|
| 274 |
}
|
| 275 |
|
| 276 |
/* Sections */
|
| 277 |
.features-section {
|
| 278 |
-
margin-bottom:
|
| 279 |
}
|
| 280 |
|
| 281 |
.section-header {
|
| 282 |
display: flex;
|
| 283 |
align-items: center;
|
| 284 |
justify-content: space-between;
|
| 285 |
-
margin-bottom:
|
| 286 |
flex-wrap: wrap;
|
| 287 |
gap: 16px;
|
|
|
|
| 288 |
}
|
| 289 |
|
| 290 |
.section-header-left {
|
|
@@ -294,53 +352,62 @@
|
|
| 294 |
}
|
| 295 |
|
| 296 |
.section-icon {
|
| 297 |
-
width:
|
| 298 |
-
height:
|
| 299 |
-
padding:
|
| 300 |
-
border-radius:
|
| 301 |
flex-shrink: 0;
|
|
|
|
| 302 |
}
|
| 303 |
|
| 304 |
.section-icon.memory {
|
| 305 |
-
background: rgba(
|
| 306 |
-
color: #
|
| 307 |
}
|
| 308 |
|
| 309 |
.section-icon.graph {
|
| 310 |
-
background: rgba(122, 156, 198, 0.15);
|
| 311 |
-
color: var(--dash-info);
|
| 312 |
}
|
| 313 |
|
| 314 |
.section-header h2 {
|
| 315 |
margin: 0;
|
| 316 |
-
font-size: 1.
|
| 317 |
-
color: var(--dash-text);
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
|
| 320 |
.section-header p {
|
| 321 |
margin: 4px 0 0;
|
| 322 |
font-size: 14px;
|
| 323 |
-
color: var(--dash-text-
|
|
|
|
| 324 |
}
|
| 325 |
|
| 326 |
.section-badge {
|
| 327 |
display: inline-flex;
|
| 328 |
align-items: center;
|
| 329 |
gap: 6px;
|
| 330 |
-
padding: 6px
|
| 331 |
-
background:
|
| 332 |
-
|
| 333 |
-
border
|
|
|
|
| 334 |
font-size: 12px;
|
| 335 |
-
|
|
|
|
| 336 |
}
|
| 337 |
|
| 338 |
.section-content {
|
| 339 |
-
background:
|
| 340 |
-
|
| 341 |
-
border
|
| 342 |
-
|
|
|
|
| 343 |
overflow: hidden;
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
.section-content-large {
|
|
@@ -353,56 +420,139 @@
|
|
| 353 |
background: transparent;
|
| 354 |
border: none;
|
| 355 |
border-radius: 0;
|
|
|
|
| 356 |
}
|
| 357 |
|
| 358 |
.section-content .memory-header,
|
| 359 |
.section-content .health-graph-header {
|
| 360 |
cursor: default;
|
| 361 |
-
background: rgba(255, 255, 255, 0.
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
padding: 16px 20px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
}
|
| 365 |
|
| 366 |
/* CTA Section */
|
| 367 |
.features-cta {
|
| 368 |
text-align: center;
|
| 369 |
-
padding:
|
| 370 |
-
margin
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
}
|
| 372 |
|
| 373 |
.features-cta h3 {
|
| 374 |
-
font-size:
|
| 375 |
-
color: var(--dash-text);
|
| 376 |
-
margin: 0 0
|
|
|
|
|
|
|
|
|
|
| 377 |
}
|
| 378 |
|
| 379 |
.features-cta p {
|
| 380 |
-
color: var(--dash-text-
|
| 381 |
-
margin: 0 0
|
|
|
|
|
|
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
.cta-button {
|
| 385 |
display: inline-flex;
|
| 386 |
align-items: center;
|
| 387 |
gap: 10px;
|
| 388 |
-
padding: 14px
|
| 389 |
-
background: linear-gradient(135deg, var(--dash-accent), var(--dash-accent-dark));
|
| 390 |
color: white;
|
| 391 |
border: none;
|
| 392 |
-
border-radius:
|
| 393 |
-
font-size:
|
| 394 |
font-weight: 600;
|
| 395 |
cursor: pointer;
|
| 396 |
-
transition: all
|
|
|
|
|
|
|
| 397 |
}
|
| 398 |
|
| 399 |
.cta-button:hover {
|
| 400 |
transform: translateY(-2px);
|
| 401 |
-
box-shadow: 0 8px 24px rgba(
|
| 402 |
}
|
| 403 |
|
| 404 |
/* Responsive */
|
| 405 |
@media (max-width: 768px) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
.features-hero h1 {
|
| 407 |
font-size: 2.25rem;
|
| 408 |
}
|
|
@@ -411,6 +561,10 @@
|
|
| 411 |
grid-template-columns: 1fr;
|
| 412 |
}
|
| 413 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
.flow-diagram {
|
| 415 |
flex-direction: column;
|
| 416 |
}
|
|
@@ -422,6 +576,11 @@
|
|
| 422 |
.section-header {
|
| 423 |
flex-direction: column;
|
| 424 |
align-items: flex-start;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
}
|
| 426 |
}
|
| 427 |
|
|
|
|
| 1 |
+
/* Features Page Styles - Lumea Theme */
|
| 2 |
|
| 3 |
.features-page {
|
| 4 |
min-height: 100vh;
|
| 5 |
+
background: linear-gradient(135deg, #f8f5f0 0%, #f0ebe3 100%);
|
| 6 |
position: relative;
|
| 7 |
overflow-x: hidden;
|
| 8 |
}
|
| 9 |
|
| 10 |
+
/* Animated Background with Radial Gradients */
|
| 11 |
.features-background {
|
| 12 |
position: fixed;
|
| 13 |
inset: 0;
|
| 14 |
z-index: 0;
|
| 15 |
overflow: hidden;
|
| 16 |
pointer-events: none;
|
| 17 |
+
background:
|
| 18 |
+
radial-gradient(circle at 20% 30%, rgba(107, 145, 117, 0.08) 0%, transparent 50%),
|
| 19 |
+
radial-gradient(circle at 80% 70%, rgba(217, 169, 98, 0.06) 0%, transparent 50%),
|
| 20 |
+
radial-gradient(circle at 50% 50%, rgba(122, 156, 198, 0.04) 0%, transparent 60%);
|
| 21 |
}
|
| 22 |
|
| 23 |
.features-bg-blob {
|
| 24 |
position: absolute;
|
| 25 |
border-radius: 50%;
|
| 26 |
+
filter: blur(140px);
|
| 27 |
+
opacity: 0.15;
|
| 28 |
+
animation: blobFloat 25s ease-in-out infinite;
|
| 29 |
}
|
| 30 |
|
| 31 |
.features-bg-blob-1 {
|
| 32 |
+
width: 500px;
|
| 33 |
+
height: 500px;
|
| 34 |
+
background: radial-gradient(circle, rgba(107, 145, 117, 0.3), transparent);
|
| 35 |
+
top: -150px;
|
| 36 |
+
right: -150px;
|
| 37 |
animation-delay: 0s;
|
| 38 |
}
|
| 39 |
|
| 40 |
.features-bg-blob-2 {
|
| 41 |
+
width: 450px;
|
| 42 |
+
height: 450px;
|
| 43 |
+
background: radial-gradient(circle, rgba(217, 169, 98, 0.25), transparent);
|
| 44 |
+
bottom: -100px;
|
| 45 |
+
left: -100px;
|
| 46 |
+
animation-delay: -8s;
|
| 47 |
}
|
| 48 |
|
| 49 |
.features-bg-blob-3 {
|
| 50 |
+
width: 350px;
|
| 51 |
+
height: 350px;
|
| 52 |
+
background: radial-gradient(circle, rgba(122, 156, 198, 0.2), transparent);
|
| 53 |
+
top: 40%;
|
| 54 |
left: 50%;
|
| 55 |
transform: translate(-50%, -50%);
|
| 56 |
+
animation-delay: -16s;
|
| 57 |
}
|
| 58 |
|
| 59 |
@keyframes blobFloat {
|
| 60 |
+
0%, 100% {
|
|
|
|
|
|
|
| 61 |
transform: translate(0, 0) scale(1);
|
| 62 |
}
|
|
|
|
| 63 |
25% {
|
| 64 |
+
transform: translate(30px, -40px) scale(1.08);
|
| 65 |
}
|
|
|
|
| 66 |
50% {
|
| 67 |
+
transform: translate(-30px, 30px) scale(0.92);
|
| 68 |
}
|
|
|
|
| 69 |
75% {
|
| 70 |
+
transform: translate(40px, 15px) scale(1.04);
|
| 71 |
}
|
| 72 |
}
|
| 73 |
|
|
|
|
| 75 |
.features-content {
|
| 76 |
position: relative;
|
| 77 |
z-index: 1;
|
| 78 |
+
padding: 110px 24px 80px;
|
| 79 |
}
|
| 80 |
|
| 81 |
.features-container {
|
| 82 |
+
max-width: 1300px;
|
| 83 |
margin: 0 auto;
|
| 84 |
+
padding: 0 var(--dash-spacing-lg, 1.5rem);
|
| 85 |
}
|
| 86 |
|
| 87 |
.features-loading {
|
|
|
|
| 97 |
/* Hero Section */
|
| 98 |
.features-hero {
|
| 99 |
text-align: center;
|
| 100 |
+
padding: 40px 0 60px;
|
| 101 |
+
max-width: 900px;
|
| 102 |
+
margin: 0 auto;
|
| 103 |
}
|
| 104 |
|
| 105 |
.hero-badge {
|
| 106 |
display: inline-flex;
|
| 107 |
align-items: center;
|
| 108 |
gap: 8px;
|
| 109 |
+
padding: 8px 18px;
|
| 110 |
+
background: rgba(232, 243, 235, 0.7);
|
| 111 |
+
backdrop-filter: blur(8px);
|
| 112 |
+
border: 1px solid rgba(107, 145, 117, 0.2);
|
| 113 |
+
border-radius: var(--dash-radius-full, 9999px);
|
| 114 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 115 |
font-size: 13px;
|
| 116 |
+
font-weight: 600;
|
| 117 |
margin-bottom: 24px;
|
| 118 |
+
box-shadow: 0 2px 8px rgba(107, 145, 117, 0.1);
|
| 119 |
}
|
| 120 |
|
| 121 |
.features-hero h1 {
|
| 122 |
+
font-size: 3.25rem;
|
| 123 |
font-weight: 700;
|
| 124 |
+
color: var(--dash-text, #2d2d2d);
|
| 125 |
margin: 0 0 20px;
|
| 126 |
+
line-height: 1.15;
|
| 127 |
+
font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
|
| 128 |
+
letter-spacing: -0.02em;
|
| 129 |
}
|
| 130 |
|
| 131 |
.gradient-text {
|
| 132 |
+
color: var(--dash-accent, #6b9175);
|
| 133 |
+
position: relative;
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
.hero-description {
|
| 137 |
+
font-size: 1.125rem;
|
| 138 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 139 |
+
max-width: 680px;
|
| 140 |
+
margin: 0 auto 56px;
|
| 141 |
line-height: 1.7;
|
| 142 |
+
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 143 |
}
|
| 144 |
|
| 145 |
+
/* Hero Features Grid - Glassy Cards */
|
| 146 |
.hero-features {
|
| 147 |
display: grid;
|
| 148 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 149 |
gap: 24px;
|
| 150 |
+
max-width: 1100px;
|
| 151 |
+
margin: 0 auto 64px;
|
| 152 |
text-align: left;
|
| 153 |
}
|
| 154 |
|
| 155 |
.hero-feature {
|
| 156 |
display: flex;
|
| 157 |
+
gap: 18px;
|
| 158 |
+
padding: 24px;
|
| 159 |
+
background: rgba(255, 255, 255, 0.5);
|
| 160 |
+
backdrop-filter: blur(12px);
|
| 161 |
+
border: 1px solid rgba(107, 145, 117, 0.15);
|
| 162 |
+
border-radius: var(--dash-radius-xl, 1.5rem);
|
| 163 |
+
box-shadow: 0 8px 24px rgba(107, 145, 117, 0.08);
|
| 164 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 165 |
+
position: relative;
|
| 166 |
+
overflow: hidden;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.hero-feature::before {
|
| 170 |
+
content: '';
|
| 171 |
+
position: absolute;
|
| 172 |
+
top: 0;
|
| 173 |
+
left: 0;
|
| 174 |
+
width: 4px;
|
| 175 |
+
height: 100%;
|
| 176 |
+
background: linear-gradient(180deg, var(--dash-accent) 0%, var(--dash-accent-light) 100%);
|
| 177 |
+
opacity: 0;
|
| 178 |
+
transition: opacity var(--dash-transition-base, 250ms);
|
| 179 |
}
|
| 180 |
|
| 181 |
.hero-feature:hover {
|
| 182 |
+
transform: translateY(-4px);
|
| 183 |
+
border-color: rgba(107, 145, 117, 0.3);
|
| 184 |
+
background: rgba(255, 255, 255, 0.7);
|
| 185 |
+
box-shadow: 0 12px 32px rgba(107, 145, 117, 0.15);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.hero-feature:hover::before {
|
| 189 |
+
opacity: 1;
|
| 190 |
}
|
| 191 |
|
| 192 |
.hero-feature-icon {
|
| 193 |
+
width: 52px;
|
| 194 |
+
height: 52px;
|
| 195 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 196 |
display: flex;
|
| 197 |
align-items: center;
|
| 198 |
justify-content: center;
|
| 199 |
flex-shrink: 0;
|
| 200 |
+
position: relative;
|
| 201 |
+
background: linear-gradient(135deg, rgba(107, 145, 117, 0.15), rgba(107, 145, 117, 0.08));
|
| 202 |
}
|
| 203 |
|
| 204 |
.hero-feature-icon.memory {
|
| 205 |
+
background: linear-gradient(135deg, rgba(107, 145, 117, 0.2), rgba(107, 145, 117, 0.1));
|
| 206 |
+
color: var(--dash-accent, #6b9175);
|
| 207 |
}
|
| 208 |
|
| 209 |
.hero-feature-icon.graph {
|
| 210 |
+
background: linear-gradient(135deg, rgba(122, 156, 198, 0.2), rgba(122, 156, 198, 0.1));
|
| 211 |
+
color: var(--dash-info, #7a9cc6);
|
| 212 |
}
|
| 213 |
|
| 214 |
.hero-feature-icon.ai {
|
| 215 |
+
background: linear-gradient(135deg, rgba(74, 124, 89, 0.2), rgba(74, 124, 89, 0.1));
|
| 216 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 217 |
}
|
| 218 |
|
| 219 |
.hero-feature-content h4 {
|
| 220 |
+
margin: 0 0 4px;
|
| 221 |
+
font-size: 16px;
|
| 222 |
font-weight: 600;
|
| 223 |
+
color: var(--dash-text, #2d2d2d);
|
| 224 |
+
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 225 |
}
|
| 226 |
|
| 227 |
.hero-feature-content p {
|
| 228 |
margin: 0;
|
| 229 |
+
font-size: 14px;
|
| 230 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 231 |
+
line-height: 1.6;
|
| 232 |
+
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 233 |
}
|
| 234 |
|
| 235 |
.scroll-indicator {
|
|
|
|
| 237 |
opacity: 0.5;
|
| 238 |
}
|
| 239 |
|
| 240 |
+
/* Flow Diagram - Tinted Container */
|
| 241 |
.features-flow {
|
| 242 |
+
padding: 48px;
|
| 243 |
+
margin: 0 auto 64px;
|
| 244 |
+
max-width: 1100px;
|
| 245 |
+
background: rgba(255, 255, 255, 0.4);
|
| 246 |
+
backdrop-filter: blur(16px);
|
| 247 |
+
border: 1px solid rgba(107, 145, 117, 0.12);
|
| 248 |
+
border-radius: var(--dash-radius-xl, 1.5rem);
|
| 249 |
+
box-shadow: 0 8px 24px rgba(107, 145, 117, 0.08);
|
| 250 |
}
|
| 251 |
|
| 252 |
.features-flow h2 {
|
| 253 |
+
font-size: 1.75rem;
|
| 254 |
+
color: var(--dash-text, #2d2d2d);
|
| 255 |
+
margin: 0 0 12px;
|
| 256 |
+
text-align: center;
|
| 257 |
+
font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
|
| 258 |
+
font-weight: 600;
|
| 259 |
+
letter-spacing: -0.01em;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.features-flow > p {
|
| 263 |
+
text-align: center;
|
| 264 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 265 |
+
font-size: 14px;
|
| 266 |
+
margin: 0 0 40px;
|
| 267 |
+
max-width: 600px;
|
| 268 |
+
margin-left: auto;
|
| 269 |
+
margin-right: auto;
|
| 270 |
}
|
| 271 |
|
| 272 |
.flow-diagram {
|
| 273 |
display: flex;
|
| 274 |
align-items: center;
|
| 275 |
justify-content: center;
|
| 276 |
+
gap: 20px;
|
| 277 |
flex-wrap: wrap;
|
| 278 |
}
|
| 279 |
|
|
|
|
| 281 |
display: flex;
|
| 282 |
flex-direction: column;
|
| 283 |
align-items: center;
|
| 284 |
+
gap: 14px;
|
| 285 |
}
|
| 286 |
|
| 287 |
.flow-icon {
|
| 288 |
+
width: 72px;
|
| 289 |
+
height: 72px;
|
| 290 |
+
border-radius: var(--dash-radius-xl, 1.5rem);
|
| 291 |
+
background: rgba(255, 255, 255, 0.7);
|
| 292 |
+
backdrop-filter: blur(8px);
|
| 293 |
+
border: 2px solid rgba(107, 145, 117, 0.2);
|
| 294 |
display: flex;
|
| 295 |
align-items: center;
|
| 296 |
justify-content: center;
|
| 297 |
+
color: var(--dash-text, #2d2d2d);
|
| 298 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 299 |
+
box-shadow: 0 4px 12px rgba(107, 145, 117, 0.08);
|
| 300 |
}
|
| 301 |
|
| 302 |
.flow-icon.memory {
|
| 303 |
+
border-color: rgba(107, 145, 117, 0.3);
|
| 304 |
+
color: var(--dash-accent, #6b9175);
|
| 305 |
+
background: linear-gradient(135deg, rgba(232, 243, 235, 0.8), rgba(255, 255, 255, 0.7));
|
| 306 |
}
|
| 307 |
|
| 308 |
.flow-icon.graph {
|
| 309 |
+
border-color: rgba(122, 156, 198, 0.3);
|
| 310 |
+
color: var(--dash-info, #7a9cc6);
|
| 311 |
+
background: linear-gradient(135deg, rgba(122, 156, 198, 0.15), rgba(255, 255, 255, 0.7));
|
| 312 |
}
|
| 313 |
|
| 314 |
.flow-icon.ai {
|
| 315 |
+
border-color: rgba(74, 124, 89, 0.3);
|
| 316 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 317 |
+
background: linear-gradient(135deg, rgba(232, 243, 235, 0.8), rgba(255, 255, 255, 0.7));
|
| 318 |
}
|
| 319 |
|
| 320 |
.flow-step span {
|
| 321 |
+
font-size: 14px;
|
| 322 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 323 |
+
font-weight: 600;
|
| 324 |
+
text-align: center;
|
| 325 |
}
|
| 326 |
|
| 327 |
.flow-arrow {
|
| 328 |
+
color: var(--dash-accent-light, #8fb199);
|
| 329 |
+
opacity: 0.5;
|
| 330 |
+
font-size: 20px;
|
| 331 |
}
|
| 332 |
|
| 333 |
/* Sections */
|
| 334 |
.features-section {
|
| 335 |
+
margin-bottom: 56px;
|
| 336 |
}
|
| 337 |
|
| 338 |
.section-header {
|
| 339 |
display: flex;
|
| 340 |
align-items: center;
|
| 341 |
justify-content: space-between;
|
| 342 |
+
margin-bottom: 24px;
|
| 343 |
flex-wrap: wrap;
|
| 344 |
gap: 16px;
|
| 345 |
+
padding: 0 var(--dash-spacing-md, 1rem);
|
| 346 |
}
|
| 347 |
|
| 348 |
.section-header-left {
|
|
|
|
| 352 |
}
|
| 353 |
|
| 354 |
.section-icon {
|
| 355 |
+
width: 48px;
|
| 356 |
+
height: 48px;
|
| 357 |
+
padding: 12px;
|
| 358 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 359 |
flex-shrink: 0;
|
| 360 |
+
backdrop-filter: blur(8px);
|
| 361 |
}
|
| 362 |
|
| 363 |
.section-icon.memory {
|
| 364 |
+
background: linear-gradient(135deg, rgba(107, 145, 117, 0.15), rgba(107, 145, 117, 0.08));
|
| 365 |
+
color: var(--dash-accent, #6b9175);
|
| 366 |
}
|
| 367 |
|
| 368 |
.section-icon.graph {
|
| 369 |
+
background: linear-gradient(135deg, rgba(122, 156, 198, 0.15), rgba(122, 156, 198, 0.08));
|
| 370 |
+
color: var(--dash-info, #7a9cc6);
|
| 371 |
}
|
| 372 |
|
| 373 |
.section-header h2 {
|
| 374 |
margin: 0;
|
| 375 |
+
font-size: 1.5rem;
|
| 376 |
+
color: var(--dash-text, #2d2d2d);
|
| 377 |
+
font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
|
| 378 |
+
font-weight: 600;
|
| 379 |
+
letter-spacing: -0.01em;
|
| 380 |
}
|
| 381 |
|
| 382 |
.section-header p {
|
| 383 |
margin: 4px 0 0;
|
| 384 |
font-size: 14px;
|
| 385 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 386 |
+
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 387 |
}
|
| 388 |
|
| 389 |
.section-badge {
|
| 390 |
display: inline-flex;
|
| 391 |
align-items: center;
|
| 392 |
gap: 6px;
|
| 393 |
+
padding: 6px 14px;
|
| 394 |
+
background: rgba(255, 255, 255, 0.6);
|
| 395 |
+
backdrop-filter: blur(8px);
|
| 396 |
+
border: 1px solid rgba(107, 145, 117, 0.15);
|
| 397 |
+
border-radius: var(--dash-radius-full, 9999px);
|
| 398 |
font-size: 12px;
|
| 399 |
+
font-weight: 600;
|
| 400 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 401 |
}
|
| 402 |
|
| 403 |
.section-content {
|
| 404 |
+
background: rgba(255, 255, 255, 0.5);
|
| 405 |
+
backdrop-filter: blur(12px);
|
| 406 |
+
border: 1px solid rgba(107, 145, 117, 0.12);
|
| 407 |
+
border-radius: var(--dash-radius-xl, 1.5rem);
|
| 408 |
+
padding: 6px;
|
| 409 |
overflow: hidden;
|
| 410 |
+
box-shadow: 0 8px 24px rgba(107, 145, 117, 0.08);
|
| 411 |
}
|
| 412 |
|
| 413 |
.section-content-large {
|
|
|
|
| 420 |
background: transparent;
|
| 421 |
border: none;
|
| 422 |
border-radius: 0;
|
| 423 |
+
box-shadow: none;
|
| 424 |
}
|
| 425 |
|
| 426 |
.section-content .memory-header,
|
| 427 |
.section-content .health-graph-header {
|
| 428 |
cursor: default;
|
| 429 |
+
background: rgba(255, 255, 255, 0.4);
|
| 430 |
+
backdrop-filter: blur(8px);
|
| 431 |
+
border-radius: var(--dash-radius-lg, 1rem) var(--dash-radius-lg, 1rem) 0 0;
|
| 432 |
+
padding: 18px 24px;
|
| 433 |
+
border-bottom: 1px solid rgba(107, 145, 117, 0.1);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* Memory Dashboard GitHub-style */
|
| 437 |
+
.section-content .memory-list {
|
| 438 |
+
padding: 20px;
|
| 439 |
+
background: transparent;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.section-content .memory-item {
|
| 443 |
+
background: rgba(255, 255, 255, 0.45);
|
| 444 |
+
backdrop-filter: blur(8px);
|
| 445 |
+
border: 1px solid rgba(107, 145, 117, 0.15);
|
| 446 |
+
border-left: 3px solid var(--dash-accent, #6b9175);
|
| 447 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 448 |
padding: 16px 20px;
|
| 449 |
+
margin-bottom: 12px;
|
| 450 |
+
box-shadow: 0 2px 8px rgba(107, 145, 117, 0.06);
|
| 451 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.section-content .memory-item:hover {
|
| 455 |
+
background: rgba(255, 255, 255, 0.65);
|
| 456 |
+
border-left-color: var(--dash-accent-dark, #4a7c59);
|
| 457 |
+
box-shadow: 0 4px 16px rgba(107, 145, 117, 0.12);
|
| 458 |
+
transform: translateX(2px);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.section-content .memory-search {
|
| 462 |
+
background: rgba(255, 255, 255, 0.5);
|
| 463 |
+
backdrop-filter: blur(8px);
|
| 464 |
+
border: 1px solid rgba(107, 145, 117, 0.15);
|
| 465 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 466 |
+
padding: 10px 16px;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.section-content .memory-search:focus-within {
|
| 470 |
+
background: rgba(255, 255, 255, 0.7);
|
| 471 |
+
border-color: var(--dash-accent, #6b9175);
|
| 472 |
+
box-shadow: 0 0 0 3px rgba(232, 243, 235, 0.5);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/* Graph Canvas Improvements */
|
| 476 |
+
.section-content .health-graph-canvas-wrapper {
|
| 477 |
+
position: relative;
|
| 478 |
+
padding: var(--dash-spacing-xl, 2rem);
|
| 479 |
+
background: linear-gradient(135deg, rgba(232, 243, 235, 0.2) 0%, rgba(255, 255, 255, 0.3) 100%);
|
| 480 |
+
backdrop-filter: blur(8px);
|
| 481 |
+
margin: 16px;
|
| 482 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 483 |
+
border: 1px solid rgba(107, 145, 117, 0.12);
|
| 484 |
+
overflow: visible;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.section-content .health-graph-canvas {
|
| 488 |
+
min-height: 450px;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.section-content .health-graph-toolbar {
|
| 492 |
+
background: rgba(255, 255, 255, 0.5);
|
| 493 |
+
backdrop-filter: blur(8px);
|
| 494 |
+
border-bottom: 1px solid rgba(107, 145, 117, 0.1);
|
| 495 |
+
padding: 16px 24px;
|
| 496 |
}
|
| 497 |
|
| 498 |
/* CTA Section */
|
| 499 |
.features-cta {
|
| 500 |
text-align: center;
|
| 501 |
+
padding: 80px 32px;
|
| 502 |
+
margin: 64px auto 0;
|
| 503 |
+
max-width: 800px;
|
| 504 |
+
background: rgba(255, 255, 255, 0.4);
|
| 505 |
+
backdrop-filter: blur(16px);
|
| 506 |
+
border: 1px solid rgba(107, 145, 117, 0.15);
|
| 507 |
+
border-radius: var(--dash-radius-xl, 1.5rem);
|
| 508 |
+
box-shadow: 0 8px 32px rgba(107, 145, 117, 0.1);
|
| 509 |
}
|
| 510 |
|
| 511 |
.features-cta h3 {
|
| 512 |
+
font-size: 2rem;
|
| 513 |
+
color: var(--dash-text, #2d2d2d);
|
| 514 |
+
margin: 0 0 16px;
|
| 515 |
+
font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
|
| 516 |
+
font-weight: 600;
|
| 517 |
+
letter-spacing: -0.01em;
|
| 518 |
}
|
| 519 |
|
| 520 |
.features-cta p {
|
| 521 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 522 |
+
margin: 0 0 32px;
|
| 523 |
+
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 524 |
+
font-size: 1.05rem;
|
| 525 |
+
line-height: 1.6;
|
| 526 |
}
|
| 527 |
|
| 528 |
.cta-button {
|
| 529 |
display: inline-flex;
|
| 530 |
align-items: center;
|
| 531 |
gap: 10px;
|
| 532 |
+
padding: 14px 32px;
|
| 533 |
+
background: linear-gradient(135deg, var(--dash-accent, #6b9175) 0%, var(--dash-accent-dark, #4a7c59) 100%);
|
| 534 |
color: white;
|
| 535 |
border: none;
|
| 536 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 537 |
+
font-size: 16px;
|
| 538 |
font-weight: 600;
|
| 539 |
cursor: pointer;
|
| 540 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 541 |
+
font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
|
| 542 |
+
box-shadow: 0 4px 16px rgba(107, 145, 117, 0.25);
|
| 543 |
}
|
| 544 |
|
| 545 |
.cta-button:hover {
|
| 546 |
transform: translateY(-2px);
|
| 547 |
+
box-shadow: 0 8px 24px rgba(107, 145, 117, 0.35);
|
| 548 |
}
|
| 549 |
|
| 550 |
/* Responsive */
|
| 551 |
@media (max-width: 768px) {
|
| 552 |
+
.features-content {
|
| 553 |
+
padding: 100px 16px 60px;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
.features-hero h1 {
|
| 557 |
font-size: 2.25rem;
|
| 558 |
}
|
|
|
|
| 561 |
grid-template-columns: 1fr;
|
| 562 |
}
|
| 563 |
|
| 564 |
+
.features-flow {
|
| 565 |
+
padding: 32px 24px;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
.flow-diagram {
|
| 569 |
flex-direction: column;
|
| 570 |
}
|
|
|
|
| 576 |
.section-header {
|
| 577 |
flex-direction: column;
|
| 578 |
align-items: flex-start;
|
| 579 |
+
padding: 0;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.features-cta {
|
| 583 |
+
padding: 48px 24px;
|
| 584 |
}
|
| 585 |
}
|
| 586 |
|
frontend/src/pages/Features.tsx
CHANGED
|
@@ -320,6 +320,7 @@ function Features() {
|
|
| 320 |
viewport={{ once: true }}
|
| 321 |
>
|
| 322 |
<h2>How Your Data Flows</h2>
|
|
|
|
| 323 |
<div className="flow-diagram">
|
| 324 |
<div className="flow-step">
|
| 325 |
<div className="flow-icon">
|
|
|
|
| 320 |
viewport={{ once: true }}
|
| 321 |
>
|
| 322 |
<h2>How Your Data Flows</h2>
|
| 323 |
+
<p>Every piece of health information follows a carefully designed path through our intelligent systems</p>
|
| 324 |
<div className="flow-diagram">
|
| 325 |
<div className="flow-step">
|
| 326 |
<div className="flow-icon">
|
frontend/src/pages/PhysicsTwin.css
CHANGED
|
@@ -49,6 +49,40 @@
|
|
| 49 |
gap: var(--dash-spacing-lg);
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
/* ------------------------------------------------------------------ */
|
| 53 |
/* Twin card (left column) */
|
| 54 |
/* ------------------------------------------------------------------ */
|
|
@@ -299,6 +333,20 @@
|
|
| 299 |
/* ------------------------------------------------------------------ */
|
| 300 |
/* Explainability card */
|
| 301 |
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
.physics-explainability-card .dash-card-title {
|
| 303 |
display: flex;
|
| 304 |
align-items: center;
|
|
|
|
| 49 |
gap: var(--dash-spacing-lg);
|
| 50 |
}
|
| 51 |
|
| 52 |
+
/* Two cards side-by-side row: YouTube (left) + Explainability (right) */
|
| 53 |
+
.physics-cards-row {
|
| 54 |
+
display: grid;
|
| 55 |
+
grid-template-columns: 1fr 1fr;
|
| 56 |
+
gap: var(--dash-spacing-lg);
|
| 57 |
+
align-items: stretch;
|
| 58 |
+
margin-top: var(--dash-spacing-lg);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.physics-cards-row-item {
|
| 62 |
+
min-width: 0;
|
| 63 |
+
height: 100%;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.physics-cards-row-item > .dash-card {
|
| 67 |
+
height: 100%;
|
| 68 |
+
min-height: 480px;
|
| 69 |
+
max-height: 700px;
|
| 70 |
+
display: flex;
|
| 71 |
+
flex-direction: column;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.physics-cards-row-item > .dash-card .dash-card-body {
|
| 75 |
+
flex: 1;
|
| 76 |
+
overflow-y: auto;
|
| 77 |
+
overflow-x: hidden;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
@media (max-width: 768px) {
|
| 81 |
+
.physics-cards-row {
|
| 82 |
+
grid-template-columns: 1fr;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
/* ------------------------------------------------------------------ */
|
| 87 |
/* Twin card (left column) */
|
| 88 |
/* ------------------------------------------------------------------ */
|
|
|
|
| 333 |
/* ------------------------------------------------------------------ */
|
| 334 |
/* Explainability card */
|
| 335 |
/* ------------------------------------------------------------------ */
|
| 336 |
+
.physics-explainability-card {
|
| 337 |
+
display: flex;
|
| 338 |
+
flex-direction: column;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.physics-explainability-card .dash-card-body {
|
| 342 |
+
flex: 1;
|
| 343 |
+
display: flex;
|
| 344 |
+
flex-direction: column;
|
| 345 |
+
min-height: 0;
|
| 346 |
+
overflow-y: auto;
|
| 347 |
+
overflow-x: hidden;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
.physics-explainability-card .dash-card-title {
|
| 351 |
display: flex;
|
| 352 |
align-items: center;
|
frontend/src/pages/PhysicsTwin.tsx
CHANGED
|
@@ -24,7 +24,8 @@ import DashboardNavbar from '../components/dashboard/DashboardNavbar';
|
|
| 24 |
import OrganTelemetryCard from '../components/physics/OrganTelemetryCard';
|
| 25 |
import ExplainabilityCard from '../components/physics/ExplainabilityCard';
|
| 26 |
import BodyImpactOverlay from '../components/physics/BodyImpactOverlay';
|
| 27 |
-
import
|
|
|
|
| 28 |
import {
|
| 29 |
getLatestSnapshot,
|
| 30 |
submitMetrics,
|
|
@@ -242,6 +243,31 @@ function PhysicsTwin() {
|
|
| 242 |
? snapshot.organs[selectedOrgan]
|
| 243 |
: null;
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
// Current metric value from telemetry
|
| 246 |
const currentMetrics = telemetry.current?.metrics ?? snapshot?.raw_metrics ?? {};
|
| 247 |
|
|
@@ -602,15 +628,25 @@ function PhysicsTwin() {
|
|
| 602 |
organResult={selectedResult}
|
| 603 |
lastUpdated={telemetry.current?.timestamp ?? snapshot?.timestamp ?? null}
|
| 604 |
/>
|
|
|
|
|
|
|
| 605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
<ExplainabilityCard
|
| 607 |
selectedOrgan={selectedOrgan}
|
| 608 |
organResult={selectedResult}
|
| 609 |
organLabel={selectedOrgan ? (ORGAN_LABELS[selectedOrgan] || selectedOrgan) : ''}
|
| 610 |
/>
|
| 611 |
-
|
| 612 |
-
<RecommendationsCard conditions={conditions} />
|
| 613 |
-
</motion.div>
|
| 614 |
</div>
|
| 615 |
|
| 616 |
{/* Data quality disclaimer */}
|
|
|
|
| 24 |
import OrganTelemetryCard from '../components/physics/OrganTelemetryCard';
|
| 25 |
import ExplainabilityCard from '../components/physics/ExplainabilityCard';
|
| 26 |
import BodyImpactOverlay from '../components/physics/BodyImpactOverlay';
|
| 27 |
+
import YouTubeRecommendationsCard from '../components/physics/YouTubeRecommendationsCard';
|
| 28 |
+
import '../components/physics/YouTubeRecommendationsCard.css';
|
| 29 |
import {
|
| 30 |
getLatestSnapshot,
|
| 31 |
submitMetrics,
|
|
|
|
| 243 |
? snapshot.organs[selectedOrgan]
|
| 244 |
: null;
|
| 245 |
|
| 246 |
+
// Find worst performing organ for recommendations
|
| 247 |
+
const worstOrgan = useMemo(() => {
|
| 248 |
+
if (!snapshot?.organs) return null;
|
| 249 |
+
|
| 250 |
+
let worst: { organ: string; score: number; result: OrganResult } | null = null;
|
| 251 |
+
|
| 252 |
+
for (const [organKey, organData] of Object.entries(snapshot.organs)) {
|
| 253 |
+
// Skip if score is 100 (perfect health)
|
| 254 |
+
if (organData.score >= 100) continue;
|
| 255 |
+
|
| 256 |
+
if (!worst || organData.score < worst.score) {
|
| 257 |
+
worst = { organ: organKey, score: organData.score, result: organData };
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
return worst;
|
| 262 |
+
}, [snapshot]);
|
| 263 |
+
|
| 264 |
+
// Auto-select worst organ if nothing selected
|
| 265 |
+
useEffect(() => {
|
| 266 |
+
if (!selectedOrgan && worstOrgan) {
|
| 267 |
+
setSelectedOrgan(worstOrgan.organ);
|
| 268 |
+
}
|
| 269 |
+
}, [worstOrgan, selectedOrgan]);
|
| 270 |
+
|
| 271 |
// Current metric value from telemetry
|
| 272 |
const currentMetrics = telemetry.current?.metrics ?? snapshot?.raw_metrics ?? {};
|
| 273 |
|
|
|
|
| 628 |
organResult={selectedResult}
|
| 629 |
lastUpdated={telemetry.current?.timestamp ?? snapshot?.timestamp ?? null}
|
| 630 |
/>
|
| 631 |
+
</motion.div>
|
| 632 |
+
</div>
|
| 633 |
|
| 634 |
+
{/* Full-width two-column layout: YouTube (left) + Explainability (right) */}
|
| 635 |
+
<div className="physics-cards-row">
|
| 636 |
+
<div className="physics-cards-row-item">
|
| 637 |
+
<YouTubeRecommendationsCard
|
| 638 |
+
selectedOrgan={selectedOrgan}
|
| 639 |
+
organResult={selectedResult}
|
| 640 |
+
currentMetrics={currentMetrics}
|
| 641 |
+
/>
|
| 642 |
+
</div>
|
| 643 |
+
<div className="physics-cards-row-item">
|
| 644 |
<ExplainabilityCard
|
| 645 |
selectedOrgan={selectedOrgan}
|
| 646 |
organResult={selectedResult}
|
| 647 |
organLabel={selectedOrgan ? (ORGAN_LABELS[selectedOrgan] || selectedOrgan) : ''}
|
| 648 |
/>
|
| 649 |
+
</div>
|
|
|
|
|
|
|
| 650 |
</div>
|
| 651 |
|
| 652 |
{/* Data quality disclaimer */}
|
frontend/src/pages/ReportSummary.css
CHANGED
|
@@ -369,7 +369,9 @@
|
|
| 369 |
|
| 370 |
/* Center Panel - Preview */
|
| 371 |
.center-panel .panel-card {
|
| 372 |
-
min-height:
|
|
|
|
|
|
|
| 373 |
}
|
| 374 |
|
| 375 |
.preview-panel {
|
|
@@ -409,6 +411,7 @@
|
|
| 409 |
/* Preview Single */
|
| 410 |
.preview-single {
|
| 411 |
flex: 1;
|
|
|
|
| 412 |
display: flex;
|
| 413 |
flex-direction: column;
|
| 414 |
}
|
|
@@ -435,8 +438,11 @@
|
|
| 435 |
|
| 436 |
.preview-frame {
|
| 437 |
flex: 1;
|
| 438 |
-
min-height:
|
| 439 |
background: #f8f8f8;
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
| 441 |
|
| 442 |
.preview-iframe {
|
|
@@ -444,6 +450,7 @@
|
|
| 444 |
height: 100%;
|
| 445 |
min-height: 500px;
|
| 446 |
border: none;
|
|
|
|
| 447 |
}
|
| 448 |
|
| 449 |
.preview-image {
|
|
@@ -455,8 +462,10 @@
|
|
| 455 |
/* Preview Multi (Grid) */
|
| 456 |
.preview-multi {
|
| 457 |
flex: 1;
|
|
|
|
| 458 |
display: flex;
|
| 459 |
flex-direction: column;
|
|
|
|
| 460 |
}
|
| 461 |
|
| 462 |
.preview-multi-header {
|
|
|
|
| 369 |
|
| 370 |
/* Center Panel - Preview */
|
| 371 |
.center-panel .panel-card {
|
| 372 |
+
min-height: 0;
|
| 373 |
+
display: flex;
|
| 374 |
+
flex-direction: column;
|
| 375 |
}
|
| 376 |
|
| 377 |
.preview-panel {
|
|
|
|
| 411 |
/* Preview Single */
|
| 412 |
.preview-single {
|
| 413 |
flex: 1;
|
| 414 |
+
min-height: 0;
|
| 415 |
display: flex;
|
| 416 |
flex-direction: column;
|
| 417 |
}
|
|
|
|
| 438 |
|
| 439 |
.preview-frame {
|
| 440 |
flex: 1;
|
| 441 |
+
min-height: 0;
|
| 442 |
background: #f8f8f8;
|
| 443 |
+
overflow: hidden;
|
| 444 |
+
display: flex;
|
| 445 |
+
flex-direction: column;
|
| 446 |
}
|
| 447 |
|
| 448 |
.preview-iframe {
|
|
|
|
| 450 |
height: 100%;
|
| 451 |
min-height: 500px;
|
| 452 |
border: none;
|
| 453 |
+
flex: 1;
|
| 454 |
}
|
| 455 |
|
| 456 |
.preview-image {
|
|
|
|
| 462 |
/* Preview Multi (Grid) */
|
| 463 |
.preview-multi {
|
| 464 |
flex: 1;
|
| 465 |
+
min-height: 0;
|
| 466 |
display: flex;
|
| 467 |
flex-direction: column;
|
| 468 |
+
overflow-y: auto;
|
| 469 |
}
|
| 470 |
|
| 471 |
.preview-multi-header {
|
frontend/src/services/openRouterService.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* OpenRouter Service
|
| 3 |
+
* Uses AI to generate YouTube video recommendations based on health metrics.
|
| 4 |
+
* NO YouTube API needed - generates video titles and search URLs directly.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
const OPENROUTER_API_KEY = import.meta.env.VITE_OPENROUTER_API_KEY || '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';
|
| 8 |
+
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
| 9 |
+
|
| 10 |
+
export interface AbnormalMetric {
|
| 11 |
+
name: string;
|
| 12 |
+
value: number;
|
| 13 |
+
normalRange: string;
|
| 14 |
+
status: 'high' | 'low';
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface VideoRecommendation {
|
| 18 |
+
title: string;
|
| 19 |
+
description: string;
|
| 20 |
+
url: string;
|
| 21 |
+
duration: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export async function generateVideoRecommendations(
|
| 25 |
+
organ: string,
|
| 26 |
+
abnormalMetrics: AbnormalMetric[]
|
| 27 |
+
): Promise<VideoRecommendation[]> {
|
| 28 |
+
// Use fallback if API not configured
|
| 29 |
+
if (!OPENROUTER_API_KEY || OPENROUTER_API_KEY.includes('your_key')) {
|
| 30 |
+
console.warn('OpenRouter API key not configured, using fallback recommendations');
|
| 31 |
+
return generateFallbackRecommendations(organ, abnormalMetrics);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
const prompt = buildPrompt(organ, abnormalMetrics);
|
| 36 |
+
|
| 37 |
+
const response = await fetch(OPENROUTER_BASE_URL, {
|
| 38 |
+
method: 'POST',
|
| 39 |
+
headers: {
|
| 40 |
+
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
| 41 |
+
'Content-Type': 'application/json',
|
| 42 |
+
'HTTP-Referer': window.location.origin,
|
| 43 |
+
'X-Title': 'Lumea Health - Physics Twin',
|
| 44 |
+
},
|
| 45 |
+
body: JSON.stringify({
|
| 46 |
+
model: 'openai/gpt-4o-mini',
|
| 47 |
+
messages: [
|
| 48 |
+
{
|
| 49 |
+
role: 'system',
|
| 50 |
+
content: 'You are a health education expert. Generate 6 YouTube video recommendations with titles (max 60 chars), descriptions (max 100 chars), and search queries (5-8 words). Return ONLY valid JSON: {"videos":[{"title":"...","description":"...","searchQuery":"..."}]}',
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
role: 'user',
|
| 54 |
+
content: prompt,
|
| 55 |
+
},
|
| 56 |
+
],
|
| 57 |
+
temperature: 0.8,
|
| 58 |
+
max_tokens: 1200,
|
| 59 |
+
}),
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
if (!response.ok) {
|
| 63 |
+
throw new Error(`OpenRouter API error: ${response.status}`);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const data = await response.json();
|
| 67 |
+
const content = data.choices?.[0]?.message?.content?.trim() || '';
|
| 68 |
+
|
| 69 |
+
if (content) {
|
| 70 |
+
// Try to parse JSON response
|
| 71 |
+
const parsed = JSON.parse(content);
|
| 72 |
+
const videos = parsed.videos || [];
|
| 73 |
+
|
| 74 |
+
if (Array.isArray(videos) && videos.length > 0) {
|
| 75 |
+
return videos.slice(0, 6).map((v: any, index: number) => ({
|
| 76 |
+
title: v.title || `${organ} Health Video ${index + 1}`,
|
| 77 |
+
description: v.description || 'Learn how to improve your health naturally',
|
| 78 |
+
url: `https://www.youtube.com/results?search_query=${encodeURIComponent(v.searchQuery || `${organ} health`)}`,
|
| 79 |
+
duration: estimateDuration(v.searchQuery || ''),
|
| 80 |
+
}));
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return generateFallbackRecommendations(organ, abnormalMetrics);
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error('OpenRouter recommendation generation failed:', error);
|
| 87 |
+
return generateFallbackRecommendations(organ, abnormalMetrics);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function buildPrompt(organ: string, abnormalMetrics: AbnormalMetric[]): string {
|
| 92 |
+
if (abnormalMetrics.length === 0) {
|
| 93 |
+
return `Generate 6 YouTube video recommendations for improving ${organ} health. Include titles about: natural remedies, diet tips, exercises, lifestyle changes, supplements, and prevention. Make titles engaging and search queries specific.`;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const issues = abnormalMetrics
|
| 97 |
+
.slice(0, 3)
|
| 98 |
+
.map(m => `${m.name} is ${m.status} (normal: ${m.normalRange})`)
|
| 99 |
+
.join(', ');
|
| 100 |
+
|
| 101 |
+
return `Generate 6 YouTube video recommendations for ${organ} health when ${issues}. Include: diet changes, exercises, supplements, stress management, sleep tips, and medical advice. Make titles specific and actionable.`;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function generateFallbackRecommendations(organ: string, abnormalMetrics: AbnormalMetric[]): VideoRecommendation[] {
|
| 105 |
+
const organLower = organ.toLowerCase();
|
| 106 |
+
const topIssue = abnormalMetrics[0]?.name.toLowerCase() || 'health';
|
| 107 |
+
|
| 108 |
+
return [
|
| 109 |
+
{
|
| 110 |
+
title: `How to Improve ${organ} Health Naturally`,
|
| 111 |
+
description: 'Evidence-based tips for better organ function and overall wellness',
|
| 112 |
+
url: `https://www.youtube.com/results?search_query=improve+${organLower}+health+naturally`,
|
| 113 |
+
duration: '10-15 min',
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
title: `Best Foods for ${organ} Health`,
|
| 117 |
+
description: 'Nutritional guide for optimal function and disease prevention',
|
| 118 |
+
url: `https://www.youtube.com/results?search_query=best+foods+for+${organLower}`,
|
| 119 |
+
duration: '8-12 min',
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
title: `${organ} Function: What You Need to Know`,
|
| 123 |
+
description: 'Understanding how your organ works and signs of problems',
|
| 124 |
+
url: `https://www.youtube.com/results?search_query=${organLower}+function+explained`,
|
| 125 |
+
duration: '12-18 min',
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
title: `Exercises for ${organ} Health`,
|
| 129 |
+
description: 'Physical activities to support and improve organ function',
|
| 130 |
+
url: `https://www.youtube.com/results?search_query=exercises+for+${organLower}+health`,
|
| 131 |
+
duration: '15-20 min',
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
title: `Managing ${topIssue} for Better ${organ} Health`,
|
| 135 |
+
description: 'Targeted strategies for your specific health concerns',
|
| 136 |
+
url: `https://www.youtube.com/results?search_query=${topIssue}+${organLower}+health`,
|
| 137 |
+
duration: '10-15 min',
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
title: `${organ} Health Supplements and Remedies`,
|
| 141 |
+
description: 'Natural support options and what science says',
|
| 142 |
+
url: `https://www.youtube.com/results?search_query=${organLower}+supplements+remedies`,
|
| 143 |
+
duration: '8-12 min',
|
| 144 |
+
},
|
| 145 |
+
];
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function estimateDuration(searchQuery: string): string {
|
| 149 |
+
// Estimate duration based on query complexity
|
| 150 |
+
const words = searchQuery.split(' ').length;
|
| 151 |
+
if (words <= 3) return '5-10 min';
|
| 152 |
+
if (words <= 5) return '8-15 min';
|
| 153 |
+
return '10-20 min';
|
| 154 |
+
}
|
frontend/src/services/youtubeService.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* YouTube Data API v3 Service
|
| 3 |
+
* Fetches real video data with thumbnails
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface YouTubeVideo {
|
| 7 |
+
videoId: string;
|
| 8 |
+
title: string;
|
| 9 |
+
channelTitle: string;
|
| 10 |
+
publishedAt: string;
|
| 11 |
+
thumbnailUrl: string;
|
| 12 |
+
url: string;
|
| 13 |
+
description: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface YouTubeSearchResponse {
|
| 17 |
+
items: Array<{
|
| 18 |
+
id: { videoId: string };
|
| 19 |
+
snippet: {
|
| 20 |
+
title: string;
|
| 21 |
+
channelTitle: string;
|
| 22 |
+
publishedAt: string;
|
| 23 |
+
thumbnails: {
|
| 24 |
+
default?: { url: string };
|
| 25 |
+
medium?: { url: string };
|
| 26 |
+
high?: { url: string };
|
| 27 |
+
};
|
| 28 |
+
description: string;
|
| 29 |
+
};
|
| 30 |
+
}>;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const YOUTUBE_API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY;
|
| 34 |
+
const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3';
|
| 35 |
+
|
| 36 |
+
export async function searchYouTubeVideos(query: string): Promise<YouTubeVideo[]> {
|
| 37 |
+
if (!YOUTUBE_API_KEY) {
|
| 38 |
+
console.warn('YouTube API key not configured');
|
| 39 |
+
// Return placeholder data that opens YouTube search
|
| 40 |
+
return [{
|
| 41 |
+
videoId: 'placeholder',
|
| 42 |
+
title: query,
|
| 43 |
+
channelTitle: 'Search YouTube',
|
| 44 |
+
publishedAt: new Date().toISOString(),
|
| 45 |
+
thumbnailUrl: '',
|
| 46 |
+
url: `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`,
|
| 47 |
+
description: 'Click to search YouTube for this topic',
|
| 48 |
+
}];
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
const params = new URLSearchParams({
|
| 53 |
+
part: 'snippet',
|
| 54 |
+
type: 'video',
|
| 55 |
+
maxResults: '6',
|
| 56 |
+
videoEmbeddable: 'true',
|
| 57 |
+
safeSearch: 'strict',
|
| 58 |
+
relevanceLanguage: 'en',
|
| 59 |
+
q: query,
|
| 60 |
+
key: YOUTUBE_API_KEY,
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
const response = await fetch(`${YOUTUBE_API_BASE}/search?${params}`);
|
| 64 |
+
|
| 65 |
+
if (!response.ok) {
|
| 66 |
+
throw new Error(`YouTube API error: ${response.statusText}`);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const data: YouTubeSearchResponse = await response.json();
|
| 70 |
+
|
| 71 |
+
return data.items.map(item => {
|
| 72 |
+
const thumbnails = item.snippet.thumbnails;
|
| 73 |
+
const thumbnailUrl =
|
| 74 |
+
thumbnails.medium?.url ||
|
| 75 |
+
thumbnails.high?.url ||
|
| 76 |
+
thumbnails.default?.url ||
|
| 77 |
+
'';
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
videoId: item.id.videoId,
|
| 81 |
+
title: item.snippet.title,
|
| 82 |
+
channelTitle: item.snippet.channelTitle,
|
| 83 |
+
publishedAt: item.snippet.publishedAt,
|
| 84 |
+
thumbnailUrl,
|
| 85 |
+
url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
|
| 86 |
+
description: item.snippet.description,
|
| 87 |
+
};
|
| 88 |
+
});
|
| 89 |
+
} catch (error) {
|
| 90 |
+
console.error('Error fetching YouTube videos:', error);
|
| 91 |
+
throw error;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export function formatTimeAgo(dateString: string): string {
|
| 96 |
+
const date = new Date(dateString);
|
| 97 |
+
const now = new Date();
|
| 98 |
+
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
| 99 |
+
|
| 100 |
+
if (seconds < 60) return 'just now';
|
| 101 |
+
const minutes = Math.floor(seconds / 60);
|
| 102 |
+
if (minutes < 60) return `${minutes}m ago`;
|
| 103 |
+
const hours = Math.floor(minutes / 60);
|
| 104 |
+
if (hours < 24) return `${hours}h ago`;
|
| 105 |
+
const days = Math.floor(hours / 24);
|
| 106 |
+
if (days < 30) return `${days}d ago`;
|
| 107 |
+
const months = Math.floor(days / 30);
|
| 108 |
+
if (months < 12) return `${months}mo ago`;
|
| 109 |
+
const years = Math.floor(months / 12);
|
| 110 |
+
return `${years}y ago`;
|
| 111 |
+
}
|