Spaces:
Sleeping
feat: OpenRouter as primary LLM with Mem0/Graphiti intelligence layer
Browse filesMAJOR REFACTOR: Complete LLM provider architecture overhaul with knowledge graph
and memory system integration. User-scoped intelligence for multi-tenant safety.
### Backend Changes:
- llm_service: Rewrote with OpenRouter (pony-alpha) as primary provider
* Multi-tier fallback: OpenRouter pony-alpha → solar-pro-3:free → Gemini → Ollama
* Full streaming support with in-band model fallback
* Enhanced system prompt with source attribution rules for citations
- assistant_service: Integrated RAG + Memory (Mem0) + Knowledge Graph (Graphiti)
* Structured context gathering returns source metadata for citations
* Citation extraction from all three intelligence layers
* Updated docstring: "OpenRouter primary → Gemini → Ollama last resort"
- graph_service: Major refactor with user-scoping for multi-tenancy
* Added _user_node_label() and _is_user_scoped_result() for tenant isolation
* New methods: add_user_episode(), add_user_facts(), search_user()
* Groq support as alternative to Ollama for LLM operations
* Cross-encoder and embedder initialization for Graphiti
- memory_service: Groq rate-limit mitigation and exponential backoff
* New _GroqThrottle class enforces minimum interval between calls
* Configurable retry logic with jitter for 429 handling
* Batch add support for report sync operations
* Config candidates system with Groq/Ollama fallback permutations
- rag_service: Structured context with source metadata
* New get_user_context_structured() returns {"text": "...", "sources": [...]}
* Each source includes type, filename, report_id, and excerpt for citations
- document_processing: Updated to use new user-scoped graph API
* Syncs only abnormal observations to knowledge graph (prevents bloat)
* Batch memory sync to avoid Groq TPM exhaustion
* Both services called asynchronously to avoid request blocking
- enhanced_report_service: Background sync task for Memory/Graph/RAG
* New _sync_to_memory_and_graph() handles post-processing
* Syncs to ChromaDB (RAG), Neo4j (abnormal findings), Mem0 (summaries)
* Non-blocking async task spawned after report processing
- grok_recommendation_service: Updated graph search to use search_user()
### Configuration:
- settings.py: Added OpenRouter settings (key, base URL, models, timeout)
* Fallback model to upstage/solar-pro-3:free
* Mem0 rate-limit tuning params (MEM0_CALL_INTERVAL_SECONDS, retries, backoff)
* Groq support for Mem0 and Graphiti
* Marked Ollama as "LAST RESORT fallback"
- docker-compose.yml:
* Added 5 OPENROUTER_* env vars (API_KEY, BASE_URL, MODEL, FALLBACK, TIMEOUT)
* Added 7 MEM0/Graphiti rate-limit and config env vars
* Neo4j performance tuning (heap/pagecache sizing)
* Separate mem0_chroma volume to prevent ChromaDB singleton conflict
- requirements.txt:
* Added openai>=1.0.0 (used as OpenRouter SDK)
* Constrained transformers <5.0.0 (5.x breaks sentence-transformers paths)
* Added graphiti-core (core version, not extras that pull legacy deps)
* Added langchain-neo4j and rank-bm25 for Mem0 graph backend
### Frontend:
- HealthGraph.tsx:
* Added "Sync Profile Data" button to force user-scoped graph update
* Added AI-powered insights panel (temporal, relationships, contradictions)
* Normalized node IDs for consistent reference
- Features.tsx:
* Profile sync overlay with animated step indicators
* Shows progress: "Analysing profile" → "Syncing to Memory" → "Building Graph"
* Forces child components (MemoryDashboard, HealthGraph) to remount and fetch fresh data
* Auto-dismiss after 2.2s on success, 3.5s on error
- DashboardNavbar.tsx: Added Features route to nav and user dropdown
- ProfileSuccessScreen.tsx: Updated redirect to /features with sync state flag
- Styling:
* HealthGraph.css: New sync button and insights panel styles
* Features.css: Profile sync overlay with step counters and animations
### Schemas:
- schemas_memory.py: Added InsightType enum and Insight{Request,Response} models
### Tests:
- Updated test_assistant_*.py, test_graph_service.py, test_routes_*.py
* Changed graph_service.search() → search_user()
* Changed graph_service.add_episode() → add_user_episode()
* Fixed mock assertions for new user-scoped signatures
### Key Behavioral Changes:
1. **Provider Chain (Priority):**
- OpenRouter pony-alpha (primary, 200K context, free)
- OpenRouter solar-pro-3:free (fallback, 128K context, free, retiring 2 March 2026)
- Gemini Flash (Google fallback)
- Ollama MedGemma (local last resort)
2. **User-Scoped Queries:**
- All graph and memory queries now filtered by user_id
- Tenant isolation prevents cross-user data leakage
- Graph facts and memories are user-specific
3. **Citation Support:**
- Chat responses include structured citations with sources
- LLM prompted to attribute information to reports/memory/graph
- Frontend can display: "[Source: Lab Results 2024]"
4. **Rate-Limit Protection:**
- Groq calls throttled to 3.0s minimum interval
- Exponential backoff + jitter on 429 errors
- Batch operations to reduce API call volume
5. **Profile Sync Flow:**
- User completes health profile questionnaire
- Success screen redirects to /features
- Sync overlay triggers async profile→memory→graph sync
- Components remount to display fresh data
### Dependencies:
- Must set OPENROUTER_API_KEY in .env (user responsibility)
- OpenRouter account required (free tier for pony-alpha, solar-pro-3)
- Groq API key optional (auto-selects if available, falls back to Ollama)
- Solar-pro-3:free retiring 2 March 2026 (plan replacement)
### BREAKING CHANGES:
- graph_service.search() is now search_user(user_id, query) — requires user_id
- graph_service.add_episode() is now add_user_episode(user_id, content) — must scope
- RAG context is now structured dict {text, sources} instead of plain string
- Mem0 config candidates system replaces hardcoded single config
### Testing:
`docker compose up --build` and:
- POST /api/assistant/chat with test message
- WebSocket /ws for streaming responses with citations
- GET /api/graph/insights?insight_type=temporal for AI analysis
- Verify citations in Response, users scoped in memory/graph
- .env.example +28 -11
- backend/.env.example +24 -11
- backend/alembic/versions/8f1b6c345678_add_profile_completion_columns.py +4 -4
- backend/app/core/audit.py +14 -5
- backend/app/core/rate_limit.py +35 -7
- backend/app/routes/graph.py +181 -14
- backend/app/routes/memory.py +34 -11
- backend/app/routes/profile.py +278 -69
- backend/app/routes/websocket.py +83 -25
- backend/app/schemas_memory.py +29 -0
- backend/app/services/assistant_service.py +73 -33
- backend/app/services/document_processing.py +85 -9
- backend/app/services/enhanced_report_service.py +167 -1
- backend/app/services/graph_service.py +230 -43
- backend/app/services/grok_recommendation_service.py +2 -1
- backend/app/services/llm_service.py +245 -138
- backend/app/services/memory_service.py +402 -36
- backend/app/services/rag_service.py +74 -0
- backend/app/settings.py +28 -2
- backend/requirements.txt +14 -3
- backend/tests/test_assistant_integration.py +2 -4
- backend/tests/test_assistant_service.py +7 -6
- backend/tests/test_graph_service.py +20 -16
- backend/tests/test_routes_graph.py +9 -4
- backend/tests/test_routes_memory.py +13 -0
- docker-compose.yml +25 -0
- frontend/src/components/HealthGraph.css +248 -0
- frontend/src/components/HealthGraph.tsx +243 -16
- frontend/src/components/ProfileSuccessScreen.tsx +8 -31
- frontend/src/components/dashboard/DashboardNavbar.tsx +10 -1
- frontend/src/pages/Features.css +134 -0
- frontend/src/pages/Features.tsx +216 -5
|
@@ -43,21 +43,33 @@ FRONTEND_ORIGIN=http://localhost:5173
|
|
| 43 |
# =============================================================================
|
| 44 |
# SECTION 3: AI/LLM PROVIDERS [AT LEAST ONE REQUIRED]
|
| 45 |
# =============================================================================
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
-
#
|
| 49 |
-
#
|
| 50 |
-
#
|
| 51 |
-
|
| 52 |
-
#
|
| 53 |
-
#
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
GROK_API_KEY=
|
| 56 |
|
| 57 |
# ---- GOOGLE GEMINI - FALLBACK ----
|
| 58 |
# Get from: https://aistudio.google.com/apikey
|
| 59 |
# Free tier: 15 requests/min, 1M tokens/day
|
| 60 |
-
#
|
| 61 |
USE_GEMINI_FALLBACK=true
|
| 62 |
GEMINI_API_KEY=
|
| 63 |
|
|
@@ -65,9 +77,10 @@ GEMINI_API_KEY=
|
|
| 65 |
# Get from: https://platform.openai.com/api-keys
|
| 66 |
OPENAI_API_KEY=
|
| 67 |
|
| 68 |
-
# ---- OLLAMA - LOCAL/SELF-HOSTED ----
|
| 69 |
# For Docker: Use host.docker.internal:11434
|
| 70 |
# For Linux: Update extra_hosts in docker-compose.yml
|
|
|
|
| 71 |
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
| 72 |
OLLAMA_MODEL=hf.co/unsloth/medgemma-4b-it-GGUF:Q6_K_XL
|
| 73 |
|
|
@@ -100,6 +113,10 @@ NEO4J_URI=bolt://neo4j:7687
|
|
| 100 |
NEO4J_USER=neo4j
|
| 101 |
NEO4J_PASSWORD=changeme
|
| 102 |
MEM0_COLLECTION=user_memories
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
GRAPHITI_DATABASE=neo4j
|
| 104 |
|
| 105 |
# ---- GOOGLE PLACES API (Pharmacy Locator) ----
|
|
|
|
| 43 |
# =============================================================================
|
| 44 |
# SECTION 3: AI/LLM PROVIDERS [AT LEAST ONE REQUIRED]
|
| 45 |
# =============================================================================
|
| 46 |
+
# Provider priority for the chat assistant:
|
| 47 |
+
# 1. OpenRouter (pony-alpha) - PRIMARY
|
| 48 |
+
# 2. OpenRouter (solar-pro-3:free) - FREE FALLBACK
|
| 49 |
+
# 3. Gemini (gemini-flash-latest) - Google fallback
|
| 50 |
+
# 4. Ollama (MedGemma GGUF) - Local last resort
|
| 51 |
+
#
|
| 52 |
+
# Set OPENROUTER_API_KEY to enable the primary chain.
|
| 53 |
+
# Gemini & Ollama act as fallbacks if OpenRouter is unavailable.
|
| 54 |
+
|
| 55 |
+
# ---- OPENROUTER - PRIMARY [RECOMMENDED] ----
|
| 56 |
+
# Get from: https://openrouter.ai/settings/keys
|
| 57 |
+
# Provides access to hundreds of models via a single endpoint.
|
| 58 |
+
# Models: openrouter/pony-alpha (primary), upstage/solar-pro-3:free (fallback)
|
| 59 |
+
OPENROUTER_API_KEY=
|
| 60 |
+
OPENROUTER_MODEL=openrouter/pony-alpha
|
| 61 |
+
OPENROUTER_FALLBACK_MODEL=upstage/solar-pro-3:free
|
| 62 |
+
|
| 63 |
+
# ---- GROQ API ----
|
| 64 |
+
# Used for Mem0 memory layer, Graphiti knowledge graph, and extraction pipeline.
|
| 65 |
+
# Get from: https://console.groq.com/
|
| 66 |
+
GROQ_API_KEY=
|
| 67 |
GROK_API_KEY=
|
| 68 |
|
| 69 |
# ---- GOOGLE GEMINI - FALLBACK ----
|
| 70 |
# Get from: https://aistudio.google.com/apikey
|
| 71 |
# Free tier: 15 requests/min, 1M tokens/day
|
| 72 |
+
# Acts as fallback if OpenRouter is not available
|
| 73 |
USE_GEMINI_FALLBACK=true
|
| 74 |
GEMINI_API_KEY=
|
| 75 |
|
|
|
|
| 77 |
# Get from: https://platform.openai.com/api-keys
|
| 78 |
OPENAI_API_KEY=
|
| 79 |
|
| 80 |
+
# ---- OLLAMA - LOCAL/SELF-HOSTED (LAST RESORT) ----
|
| 81 |
# For Docker: Use host.docker.internal:11434
|
| 82 |
# For Linux: Update extra_hosts in docker-compose.yml
|
| 83 |
+
# Ollama is now the LAST fallback in the provider chain.
|
| 84 |
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
| 85 |
OLLAMA_MODEL=hf.co/unsloth/medgemma-4b-it-GGUF:Q6_K_XL
|
| 86 |
|
|
|
|
| 113 |
NEO4J_USER=neo4j
|
| 114 |
NEO4J_PASSWORD=changeme
|
| 115 |
MEM0_COLLECTION=user_memories
|
| 116 |
+
MEM0_EMBED_MODEL=nomic-embed-text
|
| 117 |
+
MEM0_GROQ_MODEL=llama-3.1-8b-instant
|
| 118 |
+
MEM0_PREFER_GROQ=true
|
| 119 |
+
GRAPHITI_GROQ_MODEL=moonshotai/kimi-k2-instruct-0905
|
| 120 |
GRAPHITI_DATABASE=neo4j
|
| 121 |
|
| 122 |
# ---- GOOGLE PLACES API (Pharmacy Locator) ----
|
|
@@ -39,22 +39,30 @@ FRONTEND_ORIGIN=http://localhost:5173
|
|
| 39 |
# =============================================================================
|
| 40 |
# SECTION 2: AI/LLM PROVIDERS [ONE REQUIRED]
|
| 41 |
# =============================================================================
|
| 42 |
-
#
|
| 43 |
-
#
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
-
#
|
| 47 |
-
|
| 48 |
-
# ----
|
| 49 |
-
# Get from: https://
|
| 50 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
GROK_API_KEY=gsk_your_grok_api_key_here
|
| 52 |
GROK_MODEL=grok-beta
|
| 53 |
|
| 54 |
# ---- GOOGLE GEMINI - FALLBACK ----
|
| 55 |
# Get from: https://aistudio.google.com/apikey
|
| 56 |
# Free tier: 15 requests/min, 1M tokens/day
|
| 57 |
-
#
|
| 58 |
USE_GEMINI_FALLBACK=true
|
| 59 |
GEMINI_API_KEY=AIzaSy...your_gemini_key_here
|
| 60 |
|
|
@@ -65,7 +73,8 @@ OPENAI_API_KEY=sk_...your_openai_key_here
|
|
| 65 |
OPENAI_API_BASE=https://api.openai.com/v1
|
| 66 |
OPENAI_MODEL=gpt-4o-mini
|
| 67 |
|
| 68 |
-
# ---- OLLAMA - LOCAL/SELF-HOSTED ----
|
|
|
|
| 69 |
# Run local: ollama pull medgemma
|
| 70 |
# For Docker: Use host.docker.internal:11434
|
| 71 |
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
@@ -165,6 +174,10 @@ NEO4J_PASSWORD=changeme
|
|
| 165 |
|
| 166 |
# Memory and Graph Settings
|
| 167 |
MEM0_COLLECTION=user_memories
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
GRAPHITI_DATABASE=neo4j
|
| 169 |
|
| 170 |
# =============================================================================
|
|
|
|
| 39 |
# =============================================================================
|
| 40 |
# SECTION 2: AI/LLM PROVIDERS [ONE REQUIRED]
|
| 41 |
# =============================================================================
|
| 42 |
+
# Provider priority for the chat assistant:
|
| 43 |
+
# 1. OpenRouter (pony-alpha) - PRIMARY
|
| 44 |
+
# 2. OpenRouter (solar-pro-3:free) - FREE FALLBACK
|
| 45 |
+
# 3. Gemini (gemini-flash-latest) - Google fallback
|
| 46 |
+
# 4. Ollama (MedGemma GGUF) - Local last resort
|
| 47 |
+
|
| 48 |
+
# ---- OPENROUTER - PRIMARY [RECOMMENDED] ----
|
| 49 |
+
# Get from: https://openrouter.ai/settings/keys
|
| 50 |
+
# Provides access to hundreds of models via a single endpoint.
|
| 51 |
+
OPENROUTER_API_KEY=sk-or-v1-...your_openrouter_key_here
|
| 52 |
+
OPENROUTER_MODEL=openrouter/pony-alpha
|
| 53 |
+
OPENROUTER_FALLBACK_MODEL=upstage/solar-pro-3:free
|
| 54 |
+
|
| 55 |
+
# ---- GROQ API ----
|
| 56 |
+
# Used by Mem0 memory layer, Graphiti knowledge graph, and extraction pipeline.
|
| 57 |
+
# Get from: https://console.groq.com/
|
| 58 |
+
GROQ_API_KEY=gsk_your_groq_api_key_here
|
| 59 |
GROK_API_KEY=gsk_your_grok_api_key_here
|
| 60 |
GROK_MODEL=grok-beta
|
| 61 |
|
| 62 |
# ---- GOOGLE GEMINI - FALLBACK ----
|
| 63 |
# Get from: https://aistudio.google.com/apikey
|
| 64 |
# Free tier: 15 requests/min, 1M tokens/day
|
| 65 |
+
# Acts as fallback if OpenRouter is not available
|
| 66 |
USE_GEMINI_FALLBACK=true
|
| 67 |
GEMINI_API_KEY=AIzaSy...your_gemini_key_here
|
| 68 |
|
|
|
|
| 73 |
OPENAI_API_BASE=https://api.openai.com/v1
|
| 74 |
OPENAI_MODEL=gpt-4o-mini
|
| 75 |
|
| 76 |
+
# ---- OLLAMA - LOCAL/SELF-HOSTED (LAST RESORT) ----
|
| 77 |
+
# Ollama is now the LAST fallback in the provider chain.
|
| 78 |
# Run local: ollama pull medgemma
|
| 79 |
# For Docker: Use host.docker.internal:11434
|
| 80 |
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
|
|
| 174 |
|
| 175 |
# Memory and Graph Settings
|
| 176 |
MEM0_COLLECTION=user_memories
|
| 177 |
+
MEM0_EMBED_MODEL=nomic-embed-text
|
| 178 |
+
MEM0_GROQ_MODEL=llama-3.1-8b-instant
|
| 179 |
+
MEM0_PREFER_GROQ=true
|
| 180 |
+
GRAPHITI_GROQ_MODEL=moonshotai/kimi-k2-instruct-0905
|
| 181 |
GRAPHITI_DATABASE=neo4j
|
| 182 |
|
| 183 |
# =============================================================================
|
|
@@ -20,11 +20,11 @@ depends_on: Union[str, Sequence[str], None] = None
|
|
| 20 |
|
| 21 |
def upgrade() -> None:
|
| 22 |
# Add is_completed and completed_at columns to user_profiles
|
| 23 |
-
op.add_column('user_profiles', sa.Column('is_completed', sa.Boolean(), nullable=
|
| 24 |
op.add_column('user_profiles', sa.Column('completed_at', sa.DateTime(), nullable=True))
|
| 25 |
-
|
| 26 |
-
#
|
| 27 |
-
op.
|
| 28 |
|
| 29 |
|
| 30 |
def downgrade() -> None:
|
|
|
|
| 20 |
|
| 21 |
def upgrade() -> None:
|
| 22 |
# Add is_completed and completed_at columns to user_profiles
|
| 23 |
+
op.add_column('user_profiles', sa.Column('is_completed', sa.Boolean(), nullable=False, server_default=sa.false()))
|
| 24 |
op.add_column('user_profiles', sa.Column('completed_at', sa.DateTime(), nullable=True))
|
| 25 |
+
|
| 26 |
+
# Keep the table aligned with ORM behavior (application sets value explicitly).
|
| 27 |
+
op.alter_column('user_profiles', 'is_completed', server_default=None)
|
| 28 |
|
| 29 |
|
| 30 |
def downgrade() -> None:
|
|
@@ -9,6 +9,7 @@ Logs are structured for easy parsing and compliance reporting.
|
|
| 9 |
import json
|
| 10 |
import logging
|
| 11 |
import os
|
|
|
|
| 12 |
from datetime import datetime
|
| 13 |
from typing import Optional, Dict, Any, Union
|
| 14 |
from uuid import UUID
|
|
@@ -99,7 +100,7 @@ class AuditLogger:
|
|
| 99 |
# Prevent duplicate handlers
|
| 100 |
if not self._audit_logger.handlers:
|
| 101 |
# JSON file handler for compliance
|
| 102 |
-
audit_file = os.path.join(log_dir, "hipaa_audit.jsonl")
|
| 103 |
file_handler = logging.FileHandler(audit_file)
|
| 104 |
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
| 105 |
self._audit_logger.addHandler(file_handler)
|
|
@@ -113,10 +114,18 @@ class AuditLogger:
|
|
| 113 |
|
| 114 |
def _ensure_log_dir(self):
|
| 115 |
"""Create log directory if it doesn't exist"""
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
def _serialize_id(self, value: Any) -> Optional[str]:
|
| 122 |
"""Convert UUID or other ID types to string"""
|
|
|
|
| 9 |
import json
|
| 10 |
import logging
|
| 11 |
import os
|
| 12 |
+
import tempfile
|
| 13 |
from datetime import datetime
|
| 14 |
from typing import Optional, Dict, Any, Union
|
| 15 |
from uuid import UUID
|
|
|
|
| 100 |
# Prevent duplicate handlers
|
| 101 |
if not self._audit_logger.handlers:
|
| 102 |
# JSON file handler for compliance
|
| 103 |
+
audit_file = os.path.join(self.log_dir, "hipaa_audit.jsonl")
|
| 104 |
file_handler = logging.FileHandler(audit_file)
|
| 105 |
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
| 106 |
self._audit_logger.addHandler(file_handler)
|
|
|
|
| 114 |
|
| 115 |
def _ensure_log_dir(self):
|
| 116 |
"""Create log directory if it doesn't exist"""
|
| 117 |
+
candidate_dirs = [
|
| 118 |
+
self.log_dir,
|
| 119 |
+
os.path.join(tempfile.gettempdir(), "lumea-audit"),
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
for directory in candidate_dirs:
|
| 123 |
+
try:
|
| 124 |
+
os.makedirs(directory, exist_ok=True)
|
| 125 |
+
self.log_dir = directory
|
| 126 |
+
return
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logging.warning(f"Could not create audit log directory '{directory}': {e}")
|
| 129 |
|
| 130 |
def _serialize_id(self, value: Any) -> Optional[str]:
|
| 131 |
"""Convert UUID or other ID types to string"""
|
|
@@ -8,6 +8,7 @@ Uses in-memory storage by default. For production, configure Redis backend.
|
|
| 8 |
"""
|
| 9 |
import time
|
| 10 |
import logging
|
|
|
|
| 11 |
from typing import Callable, Optional, Dict, Tuple
|
| 12 |
from collections import defaultdict
|
| 13 |
from functools import wraps
|
|
@@ -106,20 +107,35 @@ class InMemoryRateLimiter:
|
|
| 106 |
rate_limiter = InMemoryRateLimiter()
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# Rate limit configurations for different endpoints
|
| 110 |
RATE_LIMITS = {
|
| 111 |
# Auth endpoints - strict limits to prevent brute force
|
| 112 |
-
"login": (5, 60), # 5 attempts per minute
|
| 113 |
-
"signup": (3, 60),
|
| 114 |
-
"password_reset": (3, 300), # 3 reset requests per 5 minutes
|
| 115 |
|
| 116 |
# API endpoints - moderate limits
|
| 117 |
-
"api_default": (
|
| 118 |
-
"upload": (10, 60),
|
| 119 |
-
"ai_summary": (20, 60),
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
# Sensitive data endpoints - stricter
|
| 122 |
-
"phi_access": (50, 60),
|
| 123 |
}
|
| 124 |
|
| 125 |
|
|
@@ -208,9 +224,21 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
| 208 |
"/api/reports/upload": "upload",
|
| 209 |
"/api/documents/upload": "upload",
|
| 210 |
"/api/ai/": "ai_summary",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
async def dispatch(self, request: Request, call_next):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
client_ip = get_client_ip(request)
|
| 215 |
path = request.url.path
|
| 216 |
|
|
|
|
| 8 |
"""
|
| 9 |
import time
|
| 10 |
import logging
|
| 11 |
+
import os
|
| 12 |
from typing import Callable, Optional, Dict, Tuple
|
| 13 |
from collections import defaultdict
|
| 14 |
from functools import wraps
|
|
|
|
| 107 |
rate_limiter = InMemoryRateLimiter()
|
| 108 |
|
| 109 |
|
| 110 |
+
def _env_int(name: str, default: int) -> int:
|
| 111 |
+
"""Read integer env var with safe fallback."""
|
| 112 |
+
raw = os.getenv(name)
|
| 113 |
+
if raw is None:
|
| 114 |
+
return default
|
| 115 |
+
try:
|
| 116 |
+
return int(raw)
|
| 117 |
+
except (TypeError, ValueError):
|
| 118 |
+
logger.warning("Invalid %s=%r; using default=%s", name, raw, default)
|
| 119 |
+
return default
|
| 120 |
+
|
| 121 |
+
|
| 122 |
# Rate limit configurations for different endpoints
|
| 123 |
RATE_LIMITS = {
|
| 124 |
# Auth endpoints - strict limits to prevent brute force
|
| 125 |
+
"login": (_env_int("RATE_LIMIT_LOGIN", 5), 60), # 5 attempts per minute
|
| 126 |
+
"signup": (_env_int("RATE_LIMIT_SIGNUP", 3), 60), # 3 signups per minute per IP
|
| 127 |
+
"password_reset": (_env_int("RATE_LIMIT_PASSWORD_RESET", 3), 300), # 3 reset requests per 5 minutes
|
| 128 |
|
| 129 |
# API endpoints - moderate limits
|
| 130 |
+
"api_default": (_env_int("RATE_LIMIT_API_DEFAULT", 240), 60), # 240 requests per minute
|
| 131 |
+
"upload": (_env_int("RATE_LIMIT_UPLOAD", 10), 60), # 10 uploads per minute
|
| 132 |
+
"ai_summary": (_env_int("RATE_LIMIT_AI_SUMMARY", 20), 60), # 20 AI requests per minute
|
| 133 |
+
"dashboard_read": (_env_int("RATE_LIMIT_DASHBOARD_READ", 240), 60), # 240 reads/min for dashboard polling
|
| 134 |
+
"memory_graph": (_env_int("RATE_LIMIT_MEMORY_GRAPH", 180), 60), # 180 reads/min for memory/graph UIs
|
| 135 |
+
"profile_sync": (_env_int("RATE_LIMIT_PROFILE_SYNC", 40), 60), # 40 sync requests/min
|
| 136 |
|
| 137 |
# Sensitive data endpoints - stricter
|
| 138 |
+
"phi_access": (_env_int("RATE_LIMIT_PHI_ACCESS", 50), 60), # 50 PHI accesses per minute
|
| 139 |
}
|
| 140 |
|
| 141 |
|
|
|
|
| 224 |
"/api/reports/upload": "upload",
|
| 225 |
"/api/documents/upload": "upload",
|
| 226 |
"/api/ai/": "ai_summary",
|
| 227 |
+
"/api/profile/sync-to-memory": "profile_sync",
|
| 228 |
+
"/api/memory/": "memory_graph",
|
| 229 |
+
"/api/graph/": "memory_graph",
|
| 230 |
+
"/api/me/bootstrap": "dashboard_read",
|
| 231 |
+
"/api/dashboard/": "dashboard_read",
|
| 232 |
+
"/api/recommendations": "dashboard_read",
|
| 233 |
+
"/api/health-index": "dashboard_read",
|
| 234 |
+
"/api/reports": "dashboard_read",
|
| 235 |
}
|
| 236 |
|
| 237 |
async def dispatch(self, request: Request, call_next):
|
| 238 |
+
# CORS preflight and HEAD requests should not consume API quota.
|
| 239 |
+
if request.method in {"OPTIONS", "HEAD"}:
|
| 240 |
+
return await call_next(request)
|
| 241 |
+
|
| 242 |
client_ip = get_client_ip(request)
|
| 243 |
path = request.url.path
|
| 244 |
|
|
@@ -5,14 +5,12 @@ Exposes Neo4j/Graphiti knowledge graph to frontend for viewing health relationsh
|
|
| 5 |
and facts.
|
| 6 |
"""
|
| 7 |
import logging
|
|
|
|
| 8 |
from fastapi import APIRouter, Depends, Query
|
| 9 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
-
from typing import List
|
| 11 |
-
|
| 12 |
-
from app.db import get_db
|
| 13 |
from app.models import User
|
| 14 |
from app.security import get_current_user
|
| 15 |
from app.services.graph_service import get_graph_service
|
|
|
|
| 16 |
from app.schemas_memory import (
|
| 17 |
GraphDataResponse,
|
| 18 |
GraphNode,
|
|
@@ -20,6 +18,9 @@ from app.schemas_memory import (
|
|
| 20 |
GraphFactsResponse,
|
| 21 |
GraphSearchRequest,
|
| 22 |
GraphSearchResponse,
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
|
@@ -27,6 +28,15 @@ logger = logging.getLogger(__name__)
|
|
| 27 |
router = APIRouter(prefix="/api/graph", tags=["graph"])
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def _parse_graph_result_to_relationship(result: str) -> GraphRelationship:
|
| 31 |
"""
|
| 32 |
Parse a Graphiti search result string into a GraphRelationship.
|
|
@@ -38,9 +48,9 @@ def _parse_graph_result_to_relationship(result: str) -> GraphRelationship:
|
|
| 38 |
parts = result.split(" -> ")
|
| 39 |
if len(parts) >= 3:
|
| 40 |
return GraphRelationship(
|
| 41 |
-
source=parts[0].strip(),
|
| 42 |
relation=parts[1].strip(),
|
| 43 |
-
target=parts[2].strip(),
|
| 44 |
properties={}
|
| 45 |
)
|
| 46 |
|
|
@@ -48,14 +58,14 @@ def _parse_graph_result_to_relationship(result: str) -> GraphRelationship:
|
|
| 48 |
return GraphRelationship(
|
| 49 |
source="System",
|
| 50 |
relation="states",
|
| 51 |
-
target=result.strip(),
|
| 52 |
properties={}
|
| 53 |
)
|
| 54 |
except Exception:
|
| 55 |
return GraphRelationship(
|
| 56 |
source="Unknown",
|
| 57 |
relation="related_to",
|
| 58 |
-
target=result if isinstance(result, str) else str(result),
|
| 59 |
properties={}
|
| 60 |
)
|
| 61 |
|
|
@@ -86,7 +96,11 @@ async def get_user_graph_facts(
|
|
| 86 |
|
| 87 |
try:
|
| 88 |
# Search for user-relevant facts
|
| 89 |
-
results = await graph_service.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
facts = []
|
| 92 |
for result in results:
|
|
@@ -134,13 +148,14 @@ async def search_graph(
|
|
| 134 |
)
|
| 135 |
|
| 136 |
try:
|
| 137 |
-
results = await graph_service.
|
|
|
|
| 138 |
query=request.query,
|
| 139 |
limit=request.limit
|
| 140 |
)
|
| 141 |
|
| 142 |
return GraphSearchResponse(
|
| 143 |
-
results=results if isinstance(results, list) else [],
|
| 144 |
count=len(results) if isinstance(results, list) else 0,
|
| 145 |
available=True,
|
| 146 |
message=None
|
|
@@ -180,7 +195,11 @@ async def get_graph_visualization_data(
|
|
| 180 |
)
|
| 181 |
|
| 182 |
try:
|
| 183 |
-
results = await graph_service.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
# Build nodes and relationships from search results
|
| 186 |
nodes_dict = {} # Use dict to avoid duplicates
|
|
@@ -193,7 +212,7 @@ async def get_graph_visualization_data(
|
|
| 193 |
# Add source node
|
| 194 |
if rel.source not in nodes_dict:
|
| 195 |
nodes_dict[rel.source] = GraphNode(
|
| 196 |
-
id=rel.source
|
| 197 |
name=rel.source,
|
| 198 |
type=_infer_node_type(rel.source),
|
| 199 |
properties={}
|
|
@@ -202,7 +221,7 @@ async def get_graph_visualization_data(
|
|
| 202 |
# Add target node
|
| 203 |
if rel.target not in nodes_dict:
|
| 204 |
nodes_dict[rel.target] = GraphNode(
|
| 205 |
-
id=rel.target
|
| 206 |
name=rel.target,
|
| 207 |
type=_infer_node_type(rel.target),
|
| 208 |
properties={}
|
|
@@ -251,3 +270,151 @@ def _infer_node_type(node_name: str) -> str:
|
|
| 251 |
return "user"
|
| 252 |
else:
|
| 253 |
return "entity"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
and facts.
|
| 6 |
"""
|
| 7 |
import logging
|
| 8 |
+
import re
|
| 9 |
from fastapi import APIRouter, Depends, Query
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.models import User
|
| 11 |
from app.security import get_current_user
|
| 12 |
from app.services.graph_service import get_graph_service
|
| 13 |
+
from app.services.llm_service import LLMService
|
| 14 |
from app.schemas_memory import (
|
| 15 |
GraphDataResponse,
|
| 16 |
GraphNode,
|
|
|
|
| 18 |
GraphFactsResponse,
|
| 19 |
GraphSearchRequest,
|
| 20 |
GraphSearchResponse,
|
| 21 |
+
InsightType,
|
| 22 |
+
InsightRequest,
|
| 23 |
+
InsightResponse,
|
| 24 |
)
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
|
|
|
| 28 |
router = APIRouter(prefix="/api/graph", tags=["graph"])
|
| 29 |
|
| 30 |
|
| 31 |
+
def _normalize_node_id(name: str) -> str:
|
| 32 |
+
return re.sub(r"\s+", "_", name.strip().lower())
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _sanitize_user_label(value: str) -> str:
|
| 36 |
+
# Hide internal user scoping labels from API consumers.
|
| 37 |
+
return re.sub(r"User_[0-9a-fA-F-]{36}", "You", value)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def _parse_graph_result_to_relationship(result: str) -> GraphRelationship:
|
| 41 |
"""
|
| 42 |
Parse a Graphiti search result string into a GraphRelationship.
|
|
|
|
| 48 |
parts = result.split(" -> ")
|
| 49 |
if len(parts) >= 3:
|
| 50 |
return GraphRelationship(
|
| 51 |
+
source=_sanitize_user_label(parts[0].strip()),
|
| 52 |
relation=parts[1].strip(),
|
| 53 |
+
target=_sanitize_user_label(parts[2].strip()),
|
| 54 |
properties={}
|
| 55 |
)
|
| 56 |
|
|
|
|
| 58 |
return GraphRelationship(
|
| 59 |
source="System",
|
| 60 |
relation="states",
|
| 61 |
+
target=_sanitize_user_label(result.strip()),
|
| 62 |
properties={}
|
| 63 |
)
|
| 64 |
except Exception:
|
| 65 |
return GraphRelationship(
|
| 66 |
source="Unknown",
|
| 67 |
relation="related_to",
|
| 68 |
+
target=_sanitize_user_label(result if isinstance(result, str) else str(result)),
|
| 69 |
properties={}
|
| 70 |
)
|
| 71 |
|
|
|
|
| 96 |
|
| 97 |
try:
|
| 98 |
# Search for user-relevant facts
|
| 99 |
+
results = await graph_service.search_user(
|
| 100 |
+
user_id=str(current_user.id),
|
| 101 |
+
query=query,
|
| 102 |
+
limit=limit,
|
| 103 |
+
)
|
| 104 |
|
| 105 |
facts = []
|
| 106 |
for result in results:
|
|
|
|
| 148 |
)
|
| 149 |
|
| 150 |
try:
|
| 151 |
+
results = await graph_service.search_user(
|
| 152 |
+
user_id=str(current_user.id),
|
| 153 |
query=request.query,
|
| 154 |
limit=request.limit
|
| 155 |
)
|
| 156 |
|
| 157 |
return GraphSearchResponse(
|
| 158 |
+
results=[_sanitize_user_label(item) for item in results] if isinstance(results, list) else [],
|
| 159 |
count=len(results) if isinstance(results, list) else 0,
|
| 160 |
available=True,
|
| 161 |
message=None
|
|
|
|
| 195 |
)
|
| 196 |
|
| 197 |
try:
|
| 198 |
+
results = await graph_service.search_user(
|
| 199 |
+
user_id=str(current_user.id),
|
| 200 |
+
query=query,
|
| 201 |
+
limit=limit,
|
| 202 |
+
)
|
| 203 |
|
| 204 |
# Build nodes and relationships from search results
|
| 205 |
nodes_dict = {} # Use dict to avoid duplicates
|
|
|
|
| 212 |
# Add source node
|
| 213 |
if rel.source not in nodes_dict:
|
| 214 |
nodes_dict[rel.source] = GraphNode(
|
| 215 |
+
id=_normalize_node_id(rel.source),
|
| 216 |
name=rel.source,
|
| 217 |
type=_infer_node_type(rel.source),
|
| 218 |
properties={}
|
|
|
|
| 221 |
# Add target node
|
| 222 |
if rel.target not in nodes_dict:
|
| 223 |
nodes_dict[rel.target] = GraphNode(
|
| 224 |
+
id=_normalize_node_id(rel.target),
|
| 225 |
name=rel.target,
|
| 226 |
type=_infer_node_type(rel.target),
|
| 227 |
properties={}
|
|
|
|
| 270 |
return "user"
|
| 271 |
else:
|
| 272 |
return "entity"
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ============================================================================
|
| 276 |
+
# INSIGHT PROMPTS
|
| 277 |
+
# ============================================================================
|
| 278 |
+
|
| 279 |
+
INSIGHT_PROMPTS = {
|
| 280 |
+
InsightType.TEMPORAL: """You are a health data analyst. Analyze these health facts and describe how the user's metrics have changed over time.
|
| 281 |
+
|
| 282 |
+
**Health Data:**
|
| 283 |
+
{facts}
|
| 284 |
+
|
| 285 |
+
**Instructions:**
|
| 286 |
+
- Identify any temporal trends (improving, worsening, stable)
|
| 287 |
+
- Highlight significant changes between readings
|
| 288 |
+
- Note dates/timestamps when available
|
| 289 |
+
- Be clear and concise (2-3 paragraphs max)
|
| 290 |
+
- If no temporal data is available, say so clearly""",
|
| 291 |
+
|
| 292 |
+
InsightType.RELATIONSHIPS: """You are a health data analyst. Explain the connections between conditions, medications, and health factors in this user's data.
|
| 293 |
+
|
| 294 |
+
**Health Data:**
|
| 295 |
+
{facts}
|
| 296 |
+
|
| 297 |
+
**Instructions:**
|
| 298 |
+
- Identify how conditions relate to each other
|
| 299 |
+
- Explain medication-condition relationships
|
| 300 |
+
- Highlight any lifestyle factors and their connections
|
| 301 |
+
- Keep explanations patient-friendly (2-3 paragraphs max)
|
| 302 |
+
- If relationships are unclear, note what additional data would help""",
|
| 303 |
+
|
| 304 |
+
InsightType.CONTRADICTIONS: """You are a health data analyst. Look for any data conflicts or inconsistencies in this user's health information.
|
| 305 |
+
|
| 306 |
+
**Health Data:**
|
| 307 |
+
{facts}
|
| 308 |
+
|
| 309 |
+
**Instructions:**
|
| 310 |
+
- Identify any values that seem inconsistent with each other
|
| 311 |
+
- Note any concerning changes between readings
|
| 312 |
+
- Flag potential data entry errors
|
| 313 |
+
- Be specific but non-alarming (2-3 paragraphs max)
|
| 314 |
+
- If no contradictions are found, say "No obvious data conflicts detected" """,
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
INSIGHT_QUERIES = {
|
| 318 |
+
InsightType.TEMPORAL: "health metrics changes values dates time readings results",
|
| 319 |
+
InsightType.RELATIONSHIPS: "conditions medications relationships interactions causes effects",
|
| 320 |
+
InsightType.CONTRADICTIONS: "health values changes abnormal conflicts inconsistent readings",
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# Singleton LLM service for insights
|
| 325 |
+
_llm_service: LLMService | None = None
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _get_llm_service() -> LLMService:
|
| 329 |
+
global _llm_service
|
| 330 |
+
if _llm_service is None:
|
| 331 |
+
_llm_service = LLMService()
|
| 332 |
+
return _llm_service
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@router.post("/insights", response_model=InsightResponse)
|
| 336 |
+
async def generate_graph_insight(
|
| 337 |
+
request: InsightRequest,
|
| 338 |
+
current_user: User = Depends(get_current_user),
|
| 339 |
+
) -> InsightResponse:
|
| 340 |
+
"""
|
| 341 |
+
Generate LLM-powered insights from the user's knowledge graph.
|
| 342 |
+
|
| 343 |
+
Queries Graphiti for relevant facts, then uses LLM to generate
|
| 344 |
+
human-readable analysis based on the insight type:
|
| 345 |
+
- temporal: Timeline and trend analysis
|
| 346 |
+
- relationships: Health factor connections
|
| 347 |
+
- contradictions: Data inconsistencies
|
| 348 |
+
"""
|
| 349 |
+
graph_service = get_graph_service()
|
| 350 |
+
llm_service = _get_llm_service()
|
| 351 |
+
|
| 352 |
+
# Check if Graphiti is available
|
| 353 |
+
if graph_service.client is None:
|
| 354 |
+
return InsightResponse(
|
| 355 |
+
insight_type=request.insight_type,
|
| 356 |
+
content="",
|
| 357 |
+
sources=[],
|
| 358 |
+
available=False,
|
| 359 |
+
message="Knowledge graph service is not available"
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
try:
|
| 363 |
+
# 1. Get relevant facts from the graph
|
| 364 |
+
query = INSIGHT_QUERIES.get(request.insight_type, "health data")
|
| 365 |
+
facts = await graph_service.search_user(
|
| 366 |
+
user_id=str(current_user.id),
|
| 367 |
+
query=query,
|
| 368 |
+
limit=request.context_limit,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
if not facts:
|
| 372 |
+
return InsightResponse(
|
| 373 |
+
insight_type=request.insight_type,
|
| 374 |
+
content="No health data found in your knowledge graph yet. Upload reports or sync your profile to build your health graph.",
|
| 375 |
+
sources=[],
|
| 376 |
+
available=True,
|
| 377 |
+
message=None
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# 2. Sanitize user labels from facts
|
| 381 |
+
sanitized_facts = [_sanitize_user_label(f) for f in facts]
|
| 382 |
+
facts_text = "\n".join(f"• {fact}" for fact in sanitized_facts)
|
| 383 |
+
|
| 384 |
+
# 3. Build prompt and generate insight
|
| 385 |
+
prompt_template = INSIGHT_PROMPTS.get(request.insight_type)
|
| 386 |
+
if not prompt_template:
|
| 387 |
+
return InsightResponse(
|
| 388 |
+
insight_type=request.insight_type,
|
| 389 |
+
content="Unknown insight type",
|
| 390 |
+
sources=[],
|
| 391 |
+
available=True,
|
| 392 |
+
message="Invalid insight type"
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
prompt = prompt_template.format(facts=facts_text)
|
| 396 |
+
|
| 397 |
+
# 4. Generate insight using LLM
|
| 398 |
+
insight_content = await llm_service.generate(
|
| 399 |
+
user_message=prompt,
|
| 400 |
+
context="", # Context is already in the prompt
|
| 401 |
+
chat_history=None,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
return InsightResponse(
|
| 405 |
+
insight_type=request.insight_type,
|
| 406 |
+
content=insight_content,
|
| 407 |
+
sources=sanitized_facts,
|
| 408 |
+
available=True,
|
| 409 |
+
message=None
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
except Exception as e:
|
| 413 |
+
logger.error(f"Error generating insight for user {current_user.id}: {e}")
|
| 414 |
+
return InsightResponse(
|
| 415 |
+
insight_type=request.insight_type,
|
| 416 |
+
content="",
|
| 417 |
+
sources=[],
|
| 418 |
+
available=True,
|
| 419 |
+
message=f"Error generating insight: {str(e)}"
|
| 420 |
+
)
|
|
@@ -4,11 +4,9 @@ Memory API Routes
|
|
| 4 |
Exposes Mem0 memory layer to frontend for viewing, managing, and searching user memories.
|
| 5 |
"""
|
| 6 |
import logging
|
| 7 |
-
from fastapi import APIRouter, Depends
|
| 8 |
-
from
|
| 9 |
-
from typing import Dict, Any
|
| 10 |
|
| 11 |
-
from app.db import get_db
|
| 12 |
from app.models import User
|
| 13 |
from app.security import get_current_user
|
| 14 |
from app.services.memory_service import get_memory_service
|
|
@@ -26,6 +24,12 @@ logger = logging.getLogger(__name__)
|
|
| 26 |
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def _normalize_memory_item(item: Any) -> MemoryFact:
|
| 30 |
"""
|
| 31 |
Normalize a Mem0 memory item to our MemoryFact schema.
|
|
@@ -74,7 +78,11 @@ async def get_user_memories(
|
|
| 74 |
|
| 75 |
# Handle different response formats from Mem0
|
| 76 |
facts = []
|
|
|
|
|
|
|
| 77 |
if isinstance(memories, dict):
|
|
|
|
|
|
|
| 78 |
# Mem0 might return {"results": [...]} or {"memories": [...]}
|
| 79 |
memory_list = memories.get("results", memories.get("memories", []))
|
| 80 |
elif isinstance(memories, list):
|
|
@@ -92,8 +100,8 @@ async def get_user_memories(
|
|
| 92 |
return MemoryListResponse(
|
| 93 |
facts=facts,
|
| 94 |
total_count=len(facts),
|
| 95 |
-
available=
|
| 96 |
-
message=
|
| 97 |
)
|
| 98 |
|
| 99 |
except Exception as e:
|
|
@@ -180,12 +188,16 @@ async def delete_memory(
|
|
| 180 |
)
|
| 181 |
|
| 182 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
success = await memory_service.delete(memory_id)
|
| 184 |
|
| 185 |
return MemoryDeleteResponse(
|
| 186 |
success=success,
|
| 187 |
deleted_count=1 if success else 0,
|
| 188 |
-
message="Memory deleted successfully" if success else "
|
| 189 |
)
|
| 190 |
|
| 191 |
except Exception as e:
|
|
@@ -262,8 +274,19 @@ async def search_memories(
|
|
| 262 |
|
| 263 |
# Handle different response formats
|
| 264 |
facts = []
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
try:
|
| 268 |
facts.append(_normalize_memory_item(item))
|
| 269 |
except Exception as e:
|
|
@@ -273,8 +296,8 @@ async def search_memories(
|
|
| 273 |
return MemoryListResponse(
|
| 274 |
facts=facts,
|
| 275 |
total_count=len(facts),
|
| 276 |
-
available=
|
| 277 |
-
message=
|
| 278 |
)
|
| 279 |
|
| 280 |
except Exception as e:
|
|
|
|
| 4 |
Exposes Mem0 memory layer to frontend for viewing, managing, and searching user memories.
|
| 5 |
"""
|
| 6 |
import logging
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from typing import Any
|
|
|
|
| 9 |
|
|
|
|
| 10 |
from app.models import User
|
| 11 |
from app.security import get_current_user
|
| 12 |
from app.services.memory_service import get_memory_service
|
|
|
|
| 24 |
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
| 25 |
|
| 26 |
|
| 27 |
+
def _extract_memory_id(item: Any) -> str:
|
| 28 |
+
if isinstance(item, dict):
|
| 29 |
+
return str(item.get("id", item.get("memory_id", "")))
|
| 30 |
+
return ""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
def _normalize_memory_item(item: Any) -> MemoryFact:
|
| 34 |
"""
|
| 35 |
Normalize a Mem0 memory item to our MemoryFact schema.
|
|
|
|
| 78 |
|
| 79 |
# Handle different response formats from Mem0
|
| 80 |
facts = []
|
| 81 |
+
raw_service_error = getattr(memory_service, "last_error", None)
|
| 82 |
+
service_error = raw_service_error if isinstance(raw_service_error, str) and raw_service_error else None
|
| 83 |
if isinstance(memories, dict):
|
| 84 |
+
if "error" in memories:
|
| 85 |
+
service_error = str(memories["error"])
|
| 86 |
# Mem0 might return {"results": [...]} or {"memories": [...]}
|
| 87 |
memory_list = memories.get("results", memories.get("memories", []))
|
| 88 |
elif isinstance(memories, list):
|
|
|
|
| 100 |
return MemoryListResponse(
|
| 101 |
facts=facts,
|
| 102 |
total_count=len(facts),
|
| 103 |
+
available=not (service_error and len(facts) == 0),
|
| 104 |
+
message=service_error
|
| 105 |
)
|
| 106 |
|
| 107 |
except Exception as e:
|
|
|
|
| 188 |
)
|
| 189 |
|
| 190 |
try:
|
| 191 |
+
# Attempt the delete directly. Mem0 memory IDs are UUIDs and are
|
| 192 |
+
# implicitly scoped to the user who created them via the vector store.
|
| 193 |
+
# Doing a full get_all() just for ownership verification is extremely
|
| 194 |
+
# slow because every Mem0 call goes through the Groq throttle (3s+).
|
| 195 |
success = await memory_service.delete(memory_id)
|
| 196 |
|
| 197 |
return MemoryDeleteResponse(
|
| 198 |
success=success,
|
| 199 |
deleted_count=1 if success else 0,
|
| 200 |
+
message="Memory deleted successfully" if success else "Memory not found or already deleted"
|
| 201 |
)
|
| 202 |
|
| 203 |
except Exception as e:
|
|
|
|
| 274 |
|
| 275 |
# Handle different response formats
|
| 276 |
facts = []
|
| 277 |
+
raw_service_error = getattr(memory_service, "last_error", None)
|
| 278 |
+
service_error = raw_service_error if isinstance(raw_service_error, str) and raw_service_error else None
|
| 279 |
+
if isinstance(results, dict):
|
| 280 |
+
if "error" in results:
|
| 281 |
+
service_error = str(results["error"])
|
| 282 |
+
result_items = results.get("results", results.get("memories", []))
|
| 283 |
+
elif isinstance(results, list):
|
| 284 |
+
result_items = results
|
| 285 |
+
else:
|
| 286 |
+
result_items = []
|
| 287 |
+
|
| 288 |
+
if isinstance(result_items, list):
|
| 289 |
+
for item in result_items:
|
| 290 |
try:
|
| 291 |
facts.append(_normalize_memory_item(item))
|
| 292 |
except Exception as e:
|
|
|
|
| 296 |
return MemoryListResponse(
|
| 297 |
facts=facts,
|
| 298 |
total_count=len(facts),
|
| 299 |
+
available=not (service_error and len(facts) == 0),
|
| 300 |
+
message=service_error
|
| 301 |
)
|
| 302 |
|
| 303 |
except Exception as e:
|
|
@@ -8,9 +8,11 @@ Provides endpoints for managing user health profiles, including:
|
|
| 8 |
- Completion tracking and derived features
|
| 9 |
- Recompute trigger
|
| 10 |
"""
|
|
|
|
| 11 |
import logging
|
|
|
|
| 12 |
from datetime import datetime
|
| 13 |
-
from typing import List, Optional
|
| 14 |
from uuid import UUID
|
| 15 |
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
| 16 |
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -19,6 +21,7 @@ from pydantic import BaseModel
|
|
| 19 |
from app.db import get_db, async_session_maker
|
| 20 |
from app.security import get_current_user
|
| 21 |
from app.models import User
|
|
|
|
| 22 |
from app.services.profile_service import ProfileService
|
| 23 |
from app.services.recompute_service import RecomputeService
|
| 24 |
from app.schemas import (
|
|
@@ -37,6 +40,9 @@ from app.schemas import (
|
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
# ============================================================================
|
|
@@ -594,6 +600,137 @@ async def update_wizard_state(
|
|
| 594 |
# MEMORY & GRAPH SYNC
|
| 595 |
# ============================================================================
|
| 596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
@router.post("/sync-to-memory")
|
| 598 |
async def sync_profile_to_memory(
|
| 599 |
current_user: User = Depends(get_current_user),
|
|
@@ -612,83 +749,155 @@ async def sync_profile_to_memory(
|
|
| 612 |
profile_data = await service.get_full_profile()
|
| 613 |
|
| 614 |
user_id = str(current_user.id)
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
# Conditions
|
| 635 |
-
for condition in profile_data.get("conditions", []):
|
| 636 |
-
if condition.condition_name:
|
| 637 |
-
facts_to_sync.append(f"User has condition: {condition.condition_name}")
|
| 638 |
-
|
| 639 |
-
# Medications
|
| 640 |
-
for med in profile_data.get("medications", []):
|
| 641 |
-
if med.medication_name:
|
| 642 |
-
fact = f"User takes medication: {med.medication_name}"
|
| 643 |
-
if med.dosage:
|
| 644 |
-
fact += f" ({med.dosage})"
|
| 645 |
-
facts_to_sync.append(fact)
|
| 646 |
-
|
| 647 |
-
# Allergies
|
| 648 |
-
for allergy in profile_data.get("allergies", []):
|
| 649 |
-
if allergy.allergen:
|
| 650 |
-
facts_to_sync.append(f"User is allergic to: {allergy.allergen}")
|
| 651 |
-
|
| 652 |
-
# Family history
|
| 653 |
-
for history in profile_data.get("family_history", []):
|
| 654 |
-
if history.condition_name and history.relation:
|
| 655 |
-
facts_to_sync.append(f"Family history: {history.relation} has {history.condition_name}")
|
| 656 |
-
|
| 657 |
# Sync to Memory (Mem0)
|
| 658 |
memory_service = get_memory_service()
|
| 659 |
-
if memory_service.is_available
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
)
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
|
| 673 |
# Sync to Graph (Neo4j/Graphiti)
|
| 674 |
graph_service = get_graph_service()
|
| 675 |
-
if graph_service.client is not None
|
| 676 |
try:
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
)
|
| 684 |
synced["graph"] = True
|
| 685 |
-
|
|
|
|
| 686 |
except Exception as e:
|
| 687 |
-
logger.warning(
|
| 688 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
return {
|
| 690 |
-
"success":
|
| 691 |
"synced": synced,
|
| 692 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
}
|
| 694 |
-
|
|
|
|
| 8 |
- Completion tracking and derived features
|
| 9 |
- Recompute trigger
|
| 10 |
"""
|
| 11 |
+
import asyncio
|
| 12 |
import logging
|
| 13 |
+
import re
|
| 14 |
from datetime import datetime
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
from uuid import UUID
|
| 17 |
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
| 21 |
from app.db import get_db, async_session_maker
|
| 22 |
from app.security import get_current_user
|
| 23 |
from app.models import User
|
| 24 |
+
from app.settings import settings
|
| 25 |
from app.services.profile_service import ProfileService
|
| 26 |
from app.services.recompute_service import RecomputeService
|
| 27 |
from app.schemas import (
|
|
|
|
| 40 |
logger = logging.getLogger(__name__)
|
| 41 |
|
| 42 |
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
| 43 |
+
MEMORY_SYNC_TIMEOUT_SECONDS = 60.0
|
| 44 |
+
MEMORY_SYNC_MAX_RETRIES = 2
|
| 45 |
+
MEMORY_SYNC_RETRY_BASE_SECONDS = 3.0
|
| 46 |
|
| 47 |
|
| 48 |
# ============================================================================
|
|
|
|
| 600 |
# MEMORY & GRAPH SYNC
|
| 601 |
# ============================================================================
|
| 602 |
|
| 603 |
+
def _safe_float(value: Any) -> Optional[float]:
|
| 604 |
+
try:
|
| 605 |
+
if value is None:
|
| 606 |
+
return None
|
| 607 |
+
return float(value)
|
| 608 |
+
except (TypeError, ValueError):
|
| 609 |
+
return None
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def _normalize_fact(text: str) -> str:
|
| 613 |
+
return " ".join(text.strip().split())
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def _build_profile_sync_facts(profile_data: Dict[str, Any]) -> List[str]:
|
| 617 |
+
"""Build direct profile facts + lightweight inferred insights for memory/graph sync."""
|
| 618 |
+
facts: List[str] = []
|
| 619 |
+
|
| 620 |
+
profile = profile_data.get("profile")
|
| 621 |
+
if profile:
|
| 622 |
+
if profile.height_cm:
|
| 623 |
+
facts.append(f"height is {profile.height_cm} cm")
|
| 624 |
+
if profile.weight_kg:
|
| 625 |
+
facts.append(f"weight is {profile.weight_kg} kg")
|
| 626 |
+
if profile.sex_at_birth:
|
| 627 |
+
facts.append(f"sex at birth is {profile.sex_at_birth}")
|
| 628 |
+
if profile.activity_level:
|
| 629 |
+
facts.append(f"activity level is {profile.activity_level}")
|
| 630 |
+
if profile.sleep_hours_avg:
|
| 631 |
+
facts.append(f"sleep is about {profile.sleep_hours_avg} hours per night")
|
| 632 |
+
if profile.diet_pattern:
|
| 633 |
+
facts.append(f"diet pattern is {profile.diet_pattern}")
|
| 634 |
+
if profile.smoking:
|
| 635 |
+
facts.append(f"smoking status is {profile.smoking}")
|
| 636 |
+
if profile.alcohol:
|
| 637 |
+
facts.append(f"alcohol use is {profile.alcohol}")
|
| 638 |
+
|
| 639 |
+
# Conditions
|
| 640 |
+
conditions = profile_data.get("conditions", []) or []
|
| 641 |
+
for condition in conditions:
|
| 642 |
+
if condition.condition_name:
|
| 643 |
+
facts.append(f"has condition {condition.condition_name}")
|
| 644 |
+
|
| 645 |
+
# Medications
|
| 646 |
+
medications = profile_data.get("medications", []) or []
|
| 647 |
+
for med in medications:
|
| 648 |
+
if med.name:
|
| 649 |
+
fact = f"takes medication {med.name}"
|
| 650 |
+
if med.dose:
|
| 651 |
+
fact += f" at dose {med.dose}"
|
| 652 |
+
facts.append(fact)
|
| 653 |
+
|
| 654 |
+
# Supplements
|
| 655 |
+
supplements = profile_data.get("supplements", []) or []
|
| 656 |
+
for supp in supplements:
|
| 657 |
+
if supp.name:
|
| 658 |
+
fact = f"takes supplement {supp.name}"
|
| 659 |
+
if supp.dose:
|
| 660 |
+
fact += f" at dose {supp.dose}"
|
| 661 |
+
facts.append(fact)
|
| 662 |
+
|
| 663 |
+
# Allergies
|
| 664 |
+
allergies = profile_data.get("allergies", []) or []
|
| 665 |
+
for allergy in allergies:
|
| 666 |
+
if allergy.allergen:
|
| 667 |
+
facts.append(f"is allergic to {allergy.allergen}")
|
| 668 |
+
|
| 669 |
+
# Family history
|
| 670 |
+
family_history = profile_data.get("family_history", []) or []
|
| 671 |
+
for history in family_history:
|
| 672 |
+
if history.condition_name and history.relative_type:
|
| 673 |
+
facts.append(f"family history includes {history.relative_type} with {history.condition_name}")
|
| 674 |
+
|
| 675 |
+
# Lightweight, deterministic inferences from questionnaire data.
|
| 676 |
+
if profile:
|
| 677 |
+
height_cm = _safe_float(profile.height_cm)
|
| 678 |
+
weight_kg = _safe_float(profile.weight_kg)
|
| 679 |
+
if height_cm and height_cm > 0 and weight_kg and weight_kg > 0:
|
| 680 |
+
bmi = round(weight_kg / ((height_cm / 100.0) ** 2), 1)
|
| 681 |
+
if bmi < 18.5:
|
| 682 |
+
bmi_band = "underweight"
|
| 683 |
+
elif bmi < 25:
|
| 684 |
+
bmi_band = "normal"
|
| 685 |
+
elif bmi < 30:
|
| 686 |
+
bmi_band = "overweight"
|
| 687 |
+
else:
|
| 688 |
+
bmi_band = "obesity"
|
| 689 |
+
facts.append(f"inference: bmi is {bmi} which falls in {bmi_band} range")
|
| 690 |
+
|
| 691 |
+
exercise_minutes = _safe_float(profile.exercise_minutes_per_week)
|
| 692 |
+
if exercise_minutes is not None:
|
| 693 |
+
if exercise_minutes < 90:
|
| 694 |
+
facts.append("inference: weekly activity is low and may increase cardiometabolic risk")
|
| 695 |
+
elif exercise_minutes >= 150:
|
| 696 |
+
facts.append("inference: weekly activity meets or exceeds recommended baseline")
|
| 697 |
+
|
| 698 |
+
sleep_hours = _safe_float(profile.sleep_hours_avg)
|
| 699 |
+
if sleep_hours is not None:
|
| 700 |
+
if sleep_hours < 6:
|
| 701 |
+
facts.append("inference: habitual short sleep may affect recovery and metabolic health")
|
| 702 |
+
elif sleep_hours >= 7:
|
| 703 |
+
facts.append("inference: sleep duration is within a generally healthy range")
|
| 704 |
+
|
| 705 |
+
smoking_value = str(profile.smoking or "").strip().lower()
|
| 706 |
+
if smoking_value in {"yes", "current", "daily", "sometimes"}:
|
| 707 |
+
facts.append("inference: smoking status indicates elevated cardiovascular and respiratory risk")
|
| 708 |
+
|
| 709 |
+
alcohol_value = str(profile.alcohol or "").strip().lower()
|
| 710 |
+
if alcohol_value in {"heavy", "daily", "frequent"}:
|
| 711 |
+
facts.append("inference: alcohol pattern may impact liver and cardiometabolic risk profile")
|
| 712 |
+
|
| 713 |
+
if conditions and family_history:
|
| 714 |
+
facts.append("inference: combined personal and family history increases value of preventive monitoring")
|
| 715 |
+
if medications and allergies:
|
| 716 |
+
facts.append("inference: medication planning should remain allergy-aware")
|
| 717 |
+
|
| 718 |
+
# De-duplicate while preserving order and keeping text normalized.
|
| 719 |
+
deduped: List[str] = []
|
| 720 |
+
seen = set()
|
| 721 |
+
for fact in facts:
|
| 722 |
+
normalized = _normalize_fact(fact)
|
| 723 |
+
if not normalized:
|
| 724 |
+
continue
|
| 725 |
+
key = normalized.lower()
|
| 726 |
+
if key in seen:
|
| 727 |
+
continue
|
| 728 |
+
seen.add(key)
|
| 729 |
+
deduped.append(normalized)
|
| 730 |
+
|
| 731 |
+
return deduped
|
| 732 |
+
|
| 733 |
+
|
| 734 |
@router.post("/sync-to-memory")
|
| 735 |
async def sync_profile_to_memory(
|
| 736 |
current_user: User = Depends(get_current_user),
|
|
|
|
| 749 |
profile_data = await service.get_full_profile()
|
| 750 |
|
| 751 |
user_id = str(current_user.id)
|
| 752 |
+
facts_to_sync = _build_profile_sync_facts(profile_data)
|
| 753 |
+
synced = {
|
| 754 |
+
"memory": False,
|
| 755 |
+
"graph": False,
|
| 756 |
+
"facts_attempted": len(facts_to_sync),
|
| 757 |
+
"facts_synced": 0,
|
| 758 |
+
"memory_facts_synced": 0,
|
| 759 |
+
"graph_facts_synced": 0,
|
| 760 |
+
}
|
| 761 |
+
errors: List[str] = []
|
| 762 |
+
|
| 763 |
+
if not facts_to_sync:
|
| 764 |
+
return {
|
| 765 |
+
"success": False,
|
| 766 |
+
"synced": synced,
|
| 767 |
+
"errors": ["No profile facts were available to sync yet."],
|
| 768 |
+
"message": "No profile facts available to sync",
|
| 769 |
+
}
|
| 770 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
# Sync to Memory (Mem0)
|
| 772 |
memory_service = get_memory_service()
|
| 773 |
+
if not memory_service.is_available:
|
| 774 |
+
errors.append("Memory service (Mem0) is not available.")
|
| 775 |
+
else:
|
| 776 |
+
batch_size = settings.MEM0_SYNC_BATCH_SIZE
|
| 777 |
+
batch_delay = settings.MEM0_BATCH_DELAY_SECONDS
|
| 778 |
+
memory_batches = [
|
| 779 |
+
facts_to_sync[i : i + batch_size]
|
| 780 |
+
for i in range(0, len(facts_to_sync), batch_size)
|
| 781 |
+
]
|
| 782 |
+
|
| 783 |
+
async def _sync_batch(batch_index: int, batch: List[str]) -> bool:
|
| 784 |
+
batch_content = "profile sync snapshot facts:\n" + "\n".join(f"- {fact}" for fact in batch)
|
| 785 |
+
|
| 786 |
+
for attempt in range(1, MEMORY_SYNC_MAX_RETRIES + 1):
|
| 787 |
+
try:
|
| 788 |
+
result = await asyncio.wait_for(
|
| 789 |
+
memory_service.add(
|
| 790 |
+
content=batch_content,
|
| 791 |
+
user_id=user_id,
|
| 792 |
+
metadata={
|
| 793 |
+
"source": "questionnaire_sync",
|
| 794 |
+
"kind": "profile_fact_batch",
|
| 795 |
+
"batch_index": batch_index,
|
| 796 |
+
"batch_size": len(batch),
|
| 797 |
+
},
|
| 798 |
+
),
|
| 799 |
+
timeout=MEMORY_SYNC_TIMEOUT_SECONDS,
|
| 800 |
+
)
|
| 801 |
+
except asyncio.TimeoutError:
|
| 802 |
+
logger.warning(
|
| 803 |
+
"Timed out syncing Mem0 batch %s for user %s after %.1fs (attempt %s/%s)",
|
| 804 |
+
batch_index,
|
| 805 |
+
user_id,
|
| 806 |
+
MEMORY_SYNC_TIMEOUT_SECONDS,
|
| 807 |
+
attempt,
|
| 808 |
+
MEMORY_SYNC_MAX_RETRIES,
|
| 809 |
+
)
|
| 810 |
+
if attempt < MEMORY_SYNC_MAX_RETRIES:
|
| 811 |
+
await asyncio.sleep(MEMORY_SYNC_RETRY_BASE_SECONDS * attempt)
|
| 812 |
+
continue
|
| 813 |
+
return False
|
| 814 |
+
except Exception as exc:
|
| 815 |
+
logger.warning(
|
| 816 |
+
"Unexpected Mem0 batch sync error for user %s (batch %s, attempt %s/%s): %s",
|
| 817 |
+
user_id,
|
| 818 |
+
batch_index,
|
| 819 |
+
attempt,
|
| 820 |
+
MEMORY_SYNC_MAX_RETRIES,
|
| 821 |
+
exc,
|
| 822 |
+
)
|
| 823 |
+
if attempt < MEMORY_SYNC_MAX_RETRIES:
|
| 824 |
+
await asyncio.sleep(MEMORY_SYNC_RETRY_BASE_SECONDS * attempt)
|
| 825 |
+
continue
|
| 826 |
+
return False
|
| 827 |
+
|
| 828 |
+
# memory_service.add() now self-retries on 429; a returned error
|
| 829 |
+
# here means retries were exhausted or a non-429 failure occurred.
|
| 830 |
+
if isinstance(result, dict) and result.get("error"):
|
| 831 |
+
err_text = str(result["error"])
|
| 832 |
+
logger.warning(
|
| 833 |
+
"Mem0 batch sync failed for user %s (batch %s, attempt %s/%s): %s",
|
| 834 |
+
user_id,
|
| 835 |
+
batch_index,
|
| 836 |
+
attempt,
|
| 837 |
+
MEMORY_SYNC_MAX_RETRIES,
|
| 838 |
+
err_text,
|
| 839 |
+
)
|
| 840 |
+
if attempt < MEMORY_SYNC_MAX_RETRIES:
|
| 841 |
+
await asyncio.sleep(MEMORY_SYNC_RETRY_BASE_SECONDS * attempt)
|
| 842 |
+
continue
|
| 843 |
+
return False
|
| 844 |
+
|
| 845 |
+
return True
|
| 846 |
+
|
| 847 |
+
return False
|
| 848 |
+
|
| 849 |
+
memory_sync_count = 0
|
| 850 |
+
for batch_index, batch in enumerate(memory_batches, start=1):
|
| 851 |
+
batch_synced = await _sync_batch(batch_index, batch)
|
| 852 |
+
if batch_synced:
|
| 853 |
+
memory_sync_count += len(batch)
|
| 854 |
+
# Proactive inter-batch delay to avoid Groq TPM exhaustion
|
| 855 |
+
if batch_index < len(memory_batches) and batch_delay > 0:
|
| 856 |
+
logger.debug(
|
| 857 |
+
"Mem0 sync: proactive %.1fs delay before batch %s/%s",
|
| 858 |
+
batch_delay, batch_index + 1, len(memory_batches),
|
| 859 |
)
|
| 860 |
+
await asyncio.sleep(batch_delay)
|
| 861 |
+
|
| 862 |
+
if memory_sync_count < len(facts_to_sync):
|
| 863 |
+
logger.info("Mem0 sync partial success for user %s: %s/%s facts", user_id, memory_sync_count, len(facts_to_sync))
|
| 864 |
+
|
| 865 |
+
synced["memory_facts_synced"] = memory_sync_count
|
| 866 |
+
synced["memory"] = memory_sync_count > 0
|
| 867 |
+
if memory_sync_count > 0:
|
| 868 |
+
logger.info("Synced %s facts to Mem0 for user %s", memory_sync_count, user_id)
|
| 869 |
+
else:
|
| 870 |
+
errors.append(memory_service.last_error or "Failed to sync questionnaire data to Mem0.")
|
| 871 |
|
| 872 |
# Sync to Graph (Neo4j/Graphiti)
|
| 873 |
graph_service = get_graph_service()
|
| 874 |
+
if graph_service.client is not None:
|
| 875 |
try:
|
| 876 |
+
await graph_service.add_user_facts(
|
| 877 |
+
user_id=user_id,
|
| 878 |
+
facts=facts_to_sync,
|
| 879 |
+
source="questionnaire_sync",
|
| 880 |
+
timestamp=datetime.utcnow().isoformat(),
|
| 881 |
+
)
|
|
|
|
| 882 |
synced["graph"] = True
|
| 883 |
+
synced["graph_facts_synced"] = len(facts_to_sync)
|
| 884 |
+
logger.info("Synced %s facts to Neo4j for user %s", len(facts_to_sync), user_id)
|
| 885 |
except Exception as e:
|
| 886 |
+
logger.warning("Failed to sync to Neo4j: %s", e)
|
| 887 |
+
errors.append(f"Graph sync failed: {e}")
|
| 888 |
+
else:
|
| 889 |
+
errors.append("Knowledge graph service is not available.")
|
| 890 |
+
|
| 891 |
+
synced["facts_synced"] = max(synced["memory_facts_synced"], synced["graph_facts_synced"])
|
| 892 |
+
overall_success = synced["memory"] or synced["graph"]
|
| 893 |
+
|
| 894 |
return {
|
| 895 |
+
"success": overall_success,
|
| 896 |
"synced": synced,
|
| 897 |
+
"errors": errors,
|
| 898 |
+
"message": (
|
| 899 |
+
f"Synced {synced['facts_synced']} profile facts to memory/graph layers"
|
| 900 |
+
if overall_success
|
| 901 |
+
else "Profile sync failed for both memory and graph layers"
|
| 902 |
+
),
|
| 903 |
}
|
|
|
|
@@ -103,11 +103,15 @@ async def handle_chat_request(
|
|
| 103 |
):
|
| 104 |
"""
|
| 105 |
Handle streaming chat request via WebSocket.
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
Sends:
|
| 108 |
-
- chat_start: Indicates response generation started
|
| 109 |
- chat_token: Each token as it's generated
|
| 110 |
-
- chat_complete: Final message with citations
|
| 111 |
- chat_error: On any error
|
| 112 |
"""
|
| 113 |
import uuid as uuid_module
|
|
@@ -116,7 +120,7 @@ async def handle_chat_request(
|
|
| 116 |
from app.services.graph_service import get_graph_service
|
| 117 |
from app.services.llm_service import get_llm_service
|
| 118 |
from app.db import async_session
|
| 119 |
-
|
| 120 |
try:
|
| 121 |
if not message_content or not str(message_content).strip():
|
| 122 |
await websocket.send_json({
|
|
@@ -129,32 +133,36 @@ async def handle_chat_request(
|
|
| 129 |
# Send start event
|
| 130 |
await websocket.send_json({
|
| 131 |
"type": "chat_start",
|
| 132 |
-
"data": {"message": "
|
| 133 |
"timestamp": datetime.utcnow().isoformat()
|
| 134 |
})
|
| 135 |
-
|
| 136 |
# Get services
|
| 137 |
rag_service = get_rag_service()
|
| 138 |
memory_service = get_memory_service()
|
| 139 |
graph_service = get_graph_service()
|
| 140 |
llm_service = get_llm_service()
|
| 141 |
-
|
| 142 |
-
#
|
|
|
|
|
|
|
| 143 |
async with async_session() as db:
|
| 144 |
-
|
| 145 |
-
user_id=
|
| 146 |
query=message_content,
|
| 147 |
-
db=db
|
| 148 |
)
|
| 149 |
|
| 150 |
-
# Retrieve long-term memory and graph facts (best-effort)
|
| 151 |
memories = await memory_service.search(message_content, user_id=user_id, limit=5)
|
| 152 |
-
graph_facts = await graph_service.
|
| 153 |
|
| 154 |
-
# Assemble context for LLM
|
| 155 |
parts = []
|
|
|
|
|
|
|
| 156 |
if memories:
|
| 157 |
parts.append("--- RELEVANT MEMORIES (User Facts & Preferences) ---")
|
|
|
|
| 158 |
for m in memories:
|
| 159 |
if isinstance(m, str):
|
| 160 |
text = m
|
|
@@ -172,18 +180,62 @@ async def handle_chat_request(
|
|
| 172 |
if text:
|
| 173 |
parts.append(f"- {text}")
|
| 174 |
|
|
|
|
| 175 |
if graph_facts:
|
| 176 |
parts.append("\n--- MEDICAL KNOWLEDGE GRAPH (Relationships & Facts) ---")
|
|
|
|
| 177 |
for f in graph_facts:
|
| 178 |
parts.append(f"- {f}")
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
parts.append("\n--- MEDICAL DATA & REPORTS ---")
|
| 182 |
-
parts.append(
|
| 183 |
|
| 184 |
full_context = "\n".join(parts)
|
| 185 |
-
|
| 186 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
full_response = ""
|
| 188 |
async for token in llm_service.stream_generate(
|
| 189 |
user_message=message_content,
|
|
@@ -195,31 +247,37 @@ async def handle_chat_request(
|
|
| 195 |
"data": {"token": token},
|
| 196 |
"timestamp": datetime.utcnow().isoformat()
|
| 197 |
})
|
| 198 |
-
|
| 199 |
-
# Send completion event
|
| 200 |
await websocket.send_json({
|
| 201 |
"type": "chat_complete",
|
| 202 |
"data": {
|
| 203 |
"full_response": full_response,
|
| 204 |
-
"citations":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
"session_id": session_id
|
| 206 |
},
|
| 207 |
"timestamp": datetime.utcnow().isoformat()
|
| 208 |
})
|
| 209 |
|
| 210 |
-
# Update memory and graph in background
|
| 211 |
interaction = f"User: {message_content}\nAssistant: {full_response}"
|
| 212 |
asyncio.create_task(
|
| 213 |
memory_service.add(interaction, user_id=user_id, metadata={"source": "websocket_chat"})
|
| 214 |
)
|
| 215 |
asyncio.create_task(
|
| 216 |
-
graph_service.
|
| 217 |
-
|
|
|
|
| 218 |
source="user_chat_websocket",
|
| 219 |
timestamp=datetime.utcnow().isoformat(),
|
| 220 |
)
|
| 221 |
)
|
| 222 |
-
|
| 223 |
except Exception as e:
|
| 224 |
import traceback
|
| 225 |
traceback.print_exc()
|
|
|
|
| 103 |
):
|
| 104 |
"""
|
| 105 |
Handle streaming chat request via WebSocket.
|
| 106 |
+
|
| 107 |
+
Leverages RAG, Mem0 (long-term memory), and Graphiti (knowledge graph)
|
| 108 |
+
to build rich context, then streams the LLM response and sends
|
| 109 |
+
structured source references alongside the answer.
|
| 110 |
+
|
| 111 |
Sends:
|
| 112 |
+
- chat_start: Indicates response generation started (includes sources summary)
|
| 113 |
- chat_token: Each token as it's generated
|
| 114 |
+
- chat_complete: Final message with citations / references
|
| 115 |
- chat_error: On any error
|
| 116 |
"""
|
| 117 |
import uuid as uuid_module
|
|
|
|
| 120 |
from app.services.graph_service import get_graph_service
|
| 121 |
from app.services.llm_service import get_llm_service
|
| 122 |
from app.db import async_session
|
| 123 |
+
|
| 124 |
try:
|
| 125 |
if not message_content or not str(message_content).strip():
|
| 126 |
await websocket.send_json({
|
|
|
|
| 133 |
# Send start event
|
| 134 |
await websocket.send_json({
|
| 135 |
"type": "chat_start",
|
| 136 |
+
"data": {"message": "Gathering context from your health data, memories, and knowledge graph..."},
|
| 137 |
"timestamp": datetime.utcnow().isoformat()
|
| 138 |
})
|
| 139 |
+
|
| 140 |
# Get services
|
| 141 |
rag_service = get_rag_service()
|
| 142 |
memory_service = get_memory_service()
|
| 143 |
graph_service = get_graph_service()
|
| 144 |
llm_service = get_llm_service()
|
| 145 |
+
|
| 146 |
+
# ---- Parallel context retrieval from all 3 sources ----
|
| 147 |
+
user_uuid = uuid_module.UUID(user_id)
|
| 148 |
+
|
| 149 |
async with async_session() as db:
|
| 150 |
+
rag_structured = await rag_service.get_user_context_structured(
|
| 151 |
+
user_id=user_uuid,
|
| 152 |
query=message_content,
|
| 153 |
+
db=db,
|
| 154 |
)
|
| 155 |
|
|
|
|
| 156 |
memories = await memory_service.search(message_content, user_id=user_id, limit=5)
|
| 157 |
+
graph_facts = await graph_service.search_user(user_id=user_id, query=message_content, limit=5)
|
| 158 |
|
| 159 |
+
# ---- Assemble structured context for the LLM ----
|
| 160 |
parts = []
|
| 161 |
+
|
| 162 |
+
# Memories (Mem0)
|
| 163 |
if memories:
|
| 164 |
parts.append("--- RELEVANT MEMORIES (User Facts & Preferences) ---")
|
| 165 |
+
parts.append("[Source: User Memory / Health Profile]")
|
| 166 |
for m in memories:
|
| 167 |
if isinstance(m, str):
|
| 168 |
text = m
|
|
|
|
| 180 |
if text:
|
| 181 |
parts.append(f"- {text}")
|
| 182 |
|
| 183 |
+
# Knowledge Graph (Graphiti)
|
| 184 |
if graph_facts:
|
| 185 |
parts.append("\n--- MEDICAL KNOWLEDGE GRAPH (Relationships & Facts) ---")
|
| 186 |
+
parts.append("[Source: Medical Knowledge Graph / Graphiti]")
|
| 187 |
for f in graph_facts:
|
| 188 |
parts.append(f"- {f}")
|
| 189 |
|
| 190 |
+
# RAG documents (ChromaDB)
|
| 191 |
+
rag_text = rag_structured.get("text", "") if isinstance(rag_structured, dict) else str(rag_structured)
|
| 192 |
+
if rag_text:
|
| 193 |
parts.append("\n--- MEDICAL DATA & REPORTS ---")
|
| 194 |
+
parts.append(rag_text)
|
| 195 |
|
| 196 |
full_context = "\n".join(parts)
|
| 197 |
+
|
| 198 |
+
# ---- Build structured citations / references ----
|
| 199 |
+
citations = []
|
| 200 |
+
rag_sources = rag_structured.get("sources", []) if isinstance(rag_structured, dict) else []
|
| 201 |
+
|
| 202 |
+
for src in rag_sources:
|
| 203 |
+
if src.get("type") == "report":
|
| 204 |
+
citations.append({
|
| 205 |
+
"source_type": "report",
|
| 206 |
+
"label": f"Report: {src.get('filename', 'Unknown')}",
|
| 207 |
+
"report_id": src.get("report_id"),
|
| 208 |
+
"excerpt": src.get("excerpt", ""),
|
| 209 |
+
})
|
| 210 |
+
elif src.get("type") == "observations":
|
| 211 |
+
citations.append({
|
| 212 |
+
"source_type": "observations",
|
| 213 |
+
"label": f"Lab Data: {src.get('metric_name', 'health metric')}",
|
| 214 |
+
"excerpt": src.get("excerpt", ""),
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
if memories:
|
| 218 |
+
mem_excerpts = []
|
| 219 |
+
for m in memories:
|
| 220 |
+
if isinstance(m, str):
|
| 221 |
+
mem_excerpts.append(m[:120])
|
| 222 |
+
elif isinstance(m, dict):
|
| 223 |
+
t = m.get("memory") or m.get("text") or m.get("content") or ""
|
| 224 |
+
mem_excerpts.append(t[:120])
|
| 225 |
+
citations.append({
|
| 226 |
+
"source_type": "memory",
|
| 227 |
+
"label": f"User Memory ({len(memories)} facts)",
|
| 228 |
+
"excerpt": "; ".join(mem_excerpts)[:300],
|
| 229 |
+
})
|
| 230 |
+
|
| 231 |
+
if graph_facts:
|
| 232 |
+
citations.append({
|
| 233 |
+
"source_type": "knowledge_graph",
|
| 234 |
+
"label": f"Knowledge Graph ({len(graph_facts)} facts)",
|
| 235 |
+
"excerpt": "; ".join(str(f)[:80] for f in graph_facts)[:300],
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
# ---- Stream response from LLM ----
|
| 239 |
full_response = ""
|
| 240 |
async for token in llm_service.stream_generate(
|
| 241 |
user_message=message_content,
|
|
|
|
| 247 |
"data": {"token": token},
|
| 248 |
"timestamp": datetime.utcnow().isoformat()
|
| 249 |
})
|
| 250 |
+
|
| 251 |
+
# ---- Send completion event with references ----
|
| 252 |
await websocket.send_json({
|
| 253 |
"type": "chat_complete",
|
| 254 |
"data": {
|
| 255 |
"full_response": full_response,
|
| 256 |
+
"citations": citations,
|
| 257 |
+
"sources_summary": {
|
| 258 |
+
"rag_documents": len(rag_sources),
|
| 259 |
+
"memories_used": len(memories),
|
| 260 |
+
"graph_facts_used": len(graph_facts),
|
| 261 |
+
},
|
| 262 |
"session_id": session_id
|
| 263 |
},
|
| 264 |
"timestamp": datetime.utcnow().isoformat()
|
| 265 |
})
|
| 266 |
|
| 267 |
+
# ---- Update memory and graph in background ----
|
| 268 |
interaction = f"User: {message_content}\nAssistant: {full_response}"
|
| 269 |
asyncio.create_task(
|
| 270 |
memory_service.add(interaction, user_id=user_id, metadata={"source": "websocket_chat"})
|
| 271 |
)
|
| 272 |
asyncio.create_task(
|
| 273 |
+
graph_service.add_user_episode(
|
| 274 |
+
user_id=user_id,
|
| 275 |
+
content=f"User asked: {message_content}\nAssistant answered: {full_response}",
|
| 276 |
source="user_chat_websocket",
|
| 277 |
timestamp=datetime.utcnow().isoformat(),
|
| 278 |
)
|
| 279 |
)
|
| 280 |
+
|
| 281 |
except Exception as e:
|
| 282 |
import traceback
|
| 283 |
traceback.print_exc()
|
|
@@ -107,3 +107,32 @@ class GraphFactsResponse(BaseModel):
|
|
| 107 |
count: int = Field(0, description="Number of facts")
|
| 108 |
available: bool = Field(True, description="Whether graph service is available")
|
| 109 |
message: Optional[str] = Field(None, description="Optional status message")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
count: int = Field(0, description="Number of facts")
|
| 108 |
available: bool = Field(True, description="Whether graph service is available")
|
| 109 |
message: Optional[str] = Field(None, description="Optional status message")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ============================================================================
|
| 113 |
+
# GRAPH INSIGHTS (LLM-powered analysis) SCHEMAS
|
| 114 |
+
# ============================================================================
|
| 115 |
+
|
| 116 |
+
from enum import Enum
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class InsightType(str, Enum):
|
| 120 |
+
"""Types of AI-powered graph insights."""
|
| 121 |
+
TEMPORAL = "temporal" # Timeline analysis - trends over time
|
| 122 |
+
RELATIONSHIPS = "relationships" # Health connections - conditions/meds/factors
|
| 123 |
+
CONTRADICTIONS = "contradictions" # Data conflicts - inconsistencies
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class InsightRequest(BaseModel):
|
| 127 |
+
"""Request for LLM-powered graph insight."""
|
| 128 |
+
insight_type: InsightType = Field(..., description="Type of insight to generate")
|
| 129 |
+
context_limit: int = Field(default=10, ge=1, le=20, description="Max graph facts to use as context")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class InsightResponse(BaseModel):
|
| 133 |
+
"""Response containing LLM-generated insight."""
|
| 134 |
+
insight_type: InsightType = Field(..., description="Type of insight generated")
|
| 135 |
+
content: str = Field(..., description="LLM-generated insight text")
|
| 136 |
+
sources: List[str] = Field(default_factory=list, description="Graph facts used as context")
|
| 137 |
+
available: bool = Field(True, description="Whether service is available")
|
| 138 |
+
message: Optional[str] = Field(None, description="Optional status/error message")
|
|
@@ -27,7 +27,7 @@ class AssistantService:
|
|
| 27 |
1. Mem0: Long-term memory for user preferences and facts
|
| 28 |
2. Graphiti: Knowledge graph for medical reasoning and temporal facts
|
| 29 |
3. RAG: Retrieval from uploaded reports and observations
|
| 30 |
-
4. LLM: Generative response (
|
| 31 |
"""
|
| 32 |
|
| 33 |
def __init__(self, db: AsyncSession):
|
|
@@ -80,12 +80,12 @@ class AssistantService:
|
|
| 80 |
await self.db.commit()
|
| 81 |
|
| 82 |
# 3. Parallel Retrieval Phase
|
| 83 |
-
# We gather context from RAG, Memory, and Graph
|
| 84 |
-
|
| 85 |
|
| 86 |
# 4. Context Assembly
|
| 87 |
full_context = self._assemble_context(
|
| 88 |
-
rag_context=
|
| 89 |
memory_context=memory_context,
|
| 90 |
graph_context=graph_context
|
| 91 |
)
|
|
@@ -105,17 +105,18 @@ class AssistantService:
|
|
| 105 |
# Update Memory & Graph with new interaction
|
| 106 |
await self._update_memory_and_graph(user_id, message, response_content)
|
| 107 |
|
| 108 |
-
# Extract citations
|
| 109 |
-
citations = self._extract_citations(
|
| 110 |
|
| 111 |
# Save assistant message
|
|
|
|
| 112 |
assistant_msg = ChatMessage(
|
| 113 |
session_id=session_id,
|
| 114 |
role="assistant",
|
| 115 |
content=response_content,
|
| 116 |
message_metadata={
|
| 117 |
"citations": [c.dict() for c in citations],
|
| 118 |
-
"rag_sources": len(
|
| 119 |
"memories_used": len(memory_context),
|
| 120 |
"graph_facts_used": len(graph_context)
|
| 121 |
},
|
|
@@ -127,31 +128,30 @@ class AssistantService:
|
|
| 127 |
|
| 128 |
return response_content, citations, session_id, assistant_msg.id
|
| 129 |
|
| 130 |
-
async def _gather_context(self, user_id: uuid.UUID, query: str) -> Tuple[str, List[Dict], List[str]]:
|
| 131 |
"""
|
| 132 |
Retrieve context from all sources.
|
| 133 |
-
Returns: (
|
| 134 |
"""
|
| 135 |
-
# A. RAG Retrieval (
|
| 136 |
-
|
| 137 |
-
# For now, we'll use the service's high-level method for the text, and query again if we need citation metadata
|
| 138 |
-
rag_text = await self.rag_service.get_user_context(user_id, query, self.db)
|
| 139 |
|
| 140 |
# B. Memory Retrieval (User facts/prefs)
|
| 141 |
memories = await self.memory_service.search(query, user_id=str(user_id), limit=5)
|
| 142 |
|
| 143 |
# C. Graph Retrieval (Medical knowledge/relationships)
|
| 144 |
-
graph_facts = await self.graph_service.
|
| 145 |
|
| 146 |
-
return
|
| 147 |
|
| 148 |
-
def _assemble_context(self, rag_context: str, memory_context: List[Dict], graph_context: List[str]) -> str:
|
| 149 |
"""Combine all context sources into a structured string for the LLM."""
|
| 150 |
parts = []
|
| 151 |
|
| 152 |
# 1. User Memories (Preferences, facts)
|
| 153 |
if memory_context:
|
| 154 |
parts.append("--- RELEVANT MEMORIES (User Facts & Preferences) ---")
|
|
|
|
| 155 |
for m in memory_context:
|
| 156 |
if isinstance(m, str):
|
| 157 |
text = m
|
|
@@ -173,13 +173,15 @@ class AssistantService:
|
|
| 173 |
# 2. Knowledge Graph (Medical connections)
|
| 174 |
if graph_context:
|
| 175 |
parts.append("\n--- MEDICAL KNOWLEDGE GRAPH (Relationships & Facts) ---")
|
|
|
|
| 176 |
for f in graph_context:
|
| 177 |
parts.append(f"- {f}")
|
| 178 |
|
| 179 |
-
# 3. RAG Data (Reports & Lab Results)
|
| 180 |
-
if rag_context
|
|
|
|
| 181 |
parts.append("\n--- MEDICAL DATA & REPORTS ---")
|
| 182 |
-
parts.append(
|
| 183 |
|
| 184 |
return "\n".join(parts)
|
| 185 |
|
|
@@ -197,30 +199,68 @@ class AssistantService:
|
|
| 197 |
)
|
| 198 |
|
| 199 |
# Add to Graphiti (structured episodes)
|
| 200 |
-
await self.graph_service.
|
| 201 |
-
|
|
|
|
| 202 |
source="user_chat"
|
| 203 |
)
|
| 204 |
except Exception as e:
|
| 205 |
logger.error(f"Error updating memory/graph: {e}")
|
| 206 |
|
| 207 |
-
def _extract_citations(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
"""
|
| 209 |
-
|
| 210 |
-
This is a simplification. Ideally, we pass the raw RAG docs through.
|
| 211 |
"""
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
#
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
citations.append(Citation(
|
| 219 |
report_id=None,
|
| 220 |
-
metric_name="
|
| 221 |
-
value="
|
| 222 |
-
excerpt="
|
| 223 |
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
return citations
|
| 225 |
|
| 226 |
async def get_session_history(
|
|
|
|
| 27 |
1. Mem0: Long-term memory for user preferences and facts
|
| 28 |
2. Graphiti: Knowledge graph for medical reasoning and temporal facts
|
| 29 |
3. RAG: Retrieval from uploaded reports and observations
|
| 30 |
+
4. LLM: Generative response (OpenRouter primary → Gemini → Ollama last resort)
|
| 31 |
"""
|
| 32 |
|
| 33 |
def __init__(self, db: AsyncSession):
|
|
|
|
| 80 |
await self.db.commit()
|
| 81 |
|
| 82 |
# 3. Parallel Retrieval Phase
|
| 83 |
+
# We gather context from RAG (structured), Memory, and Graph
|
| 84 |
+
rag_structured, memory_context, graph_context = await self._gather_context(user_id, message)
|
| 85 |
|
| 86 |
# 4. Context Assembly
|
| 87 |
full_context = self._assemble_context(
|
| 88 |
+
rag_context=rag_structured,
|
| 89 |
memory_context=memory_context,
|
| 90 |
graph_context=graph_context
|
| 91 |
)
|
|
|
|
| 105 |
# Update Memory & Graph with new interaction
|
| 106 |
await self._update_memory_and_graph(user_id, message, response_content)
|
| 107 |
|
| 108 |
+
# Extract citations from structured RAG sources + memory/graph flags
|
| 109 |
+
citations = self._extract_citations(rag_structured, memory_context, graph_context)
|
| 110 |
|
| 111 |
# Save assistant message
|
| 112 |
+
rag_sources = rag_structured.get("sources", []) if isinstance(rag_structured, dict) else []
|
| 113 |
assistant_msg = ChatMessage(
|
| 114 |
session_id=session_id,
|
| 115 |
role="assistant",
|
| 116 |
content=response_content,
|
| 117 |
message_metadata={
|
| 118 |
"citations": [c.dict() for c in citations],
|
| 119 |
+
"rag_sources": len(rag_sources),
|
| 120 |
"memories_used": len(memory_context),
|
| 121 |
"graph_facts_used": len(graph_context)
|
| 122 |
},
|
|
|
|
| 128 |
|
| 129 |
return response_content, citations, session_id, assistant_msg.id
|
| 130 |
|
| 131 |
+
async def _gather_context(self, user_id: uuid.UUID, query: str) -> Tuple[Dict[str, Any], List[Dict], List[str]]:
|
| 132 |
"""
|
| 133 |
Retrieve context from all sources.
|
| 134 |
+
Returns: (rag_structured, memories, graph_facts)
|
| 135 |
"""
|
| 136 |
+
# A. RAG Retrieval – structured (includes source metadata for citations)
|
| 137 |
+
rag_structured = await self.rag_service.get_user_context_structured(user_id, query, self.db)
|
|
|
|
|
|
|
| 138 |
|
| 139 |
# B. Memory Retrieval (User facts/prefs)
|
| 140 |
memories = await self.memory_service.search(query, user_id=str(user_id), limit=5)
|
| 141 |
|
| 142 |
# C. Graph Retrieval (Medical knowledge/relationships)
|
| 143 |
+
graph_facts = await self.graph_service.search_user(str(user_id), query, limit=5)
|
| 144 |
|
| 145 |
+
return rag_structured, memories, graph_facts
|
| 146 |
|
| 147 |
+
def _assemble_context(self, rag_context: Dict[str, Any], memory_context: List[Dict], graph_context: List[str]) -> str:
|
| 148 |
"""Combine all context sources into a structured string for the LLM."""
|
| 149 |
parts = []
|
| 150 |
|
| 151 |
# 1. User Memories (Preferences, facts)
|
| 152 |
if memory_context:
|
| 153 |
parts.append("--- RELEVANT MEMORIES (User Facts & Preferences) ---")
|
| 154 |
+
parts.append("[Source: User Memory / Health Profile]")
|
| 155 |
for m in memory_context:
|
| 156 |
if isinstance(m, str):
|
| 157 |
text = m
|
|
|
|
| 173 |
# 2. Knowledge Graph (Medical connections)
|
| 174 |
if graph_context:
|
| 175 |
parts.append("\n--- MEDICAL KNOWLEDGE GRAPH (Relationships & Facts) ---")
|
| 176 |
+
parts.append("[Source: Medical Knowledge Graph / Graphiti]")
|
| 177 |
for f in graph_context:
|
| 178 |
parts.append(f"- {f}")
|
| 179 |
|
| 180 |
+
# 3. RAG Data (Reports & Lab Results) – use the structured text
|
| 181 |
+
rag_text = rag_context.get("text", "") if isinstance(rag_context, dict) else rag_context
|
| 182 |
+
if rag_text:
|
| 183 |
parts.append("\n--- MEDICAL DATA & REPORTS ---")
|
| 184 |
+
parts.append(rag_text)
|
| 185 |
|
| 186 |
return "\n".join(parts)
|
| 187 |
|
|
|
|
| 199 |
)
|
| 200 |
|
| 201 |
# Add to Graphiti (structured episodes)
|
| 202 |
+
await self.graph_service.add_user_episode(
|
| 203 |
+
user_id=str(user_id),
|
| 204 |
+
content=f"User asked: {user_msg}\nAssistant answered: {assistant_msg}",
|
| 205 |
source="user_chat"
|
| 206 |
)
|
| 207 |
except Exception as e:
|
| 208 |
logger.error(f"Error updating memory/graph: {e}")
|
| 209 |
|
| 210 |
+
def _extract_citations(
|
| 211 |
+
self,
|
| 212 |
+
rag_structured: Dict[str, Any],
|
| 213 |
+
memory_context: List[Dict],
|
| 214 |
+
graph_context: List[str],
|
| 215 |
+
) -> List[Citation]:
|
| 216 |
"""
|
| 217 |
+
Build Citation objects from the structured context sources.
|
|
|
|
| 218 |
"""
|
| 219 |
+
citations: List[Citation] = []
|
| 220 |
+
|
| 221 |
+
# RAG document sources
|
| 222 |
+
rag_sources = rag_structured.get("sources", []) if isinstance(rag_structured, dict) else []
|
| 223 |
+
for src in rag_sources:
|
| 224 |
+
if src.get("type") == "report":
|
| 225 |
+
citations.append(Citation(
|
| 226 |
+
report_id=src.get("report_id"),
|
| 227 |
+
metric_name=src.get("filename", "Report"),
|
| 228 |
+
value="Referenced Report",
|
| 229 |
+
excerpt=src.get("excerpt", ""),
|
| 230 |
+
))
|
| 231 |
+
elif src.get("type") == "observations":
|
| 232 |
+
citations.append(Citation(
|
| 233 |
+
report_id=None,
|
| 234 |
+
metric_name=src.get("metric_name", "Lab Observation"),
|
| 235 |
+
value="Lab Data",
|
| 236 |
+
excerpt=src.get("excerpt", ""),
|
| 237 |
+
))
|
| 238 |
+
|
| 239 |
+
# Memory source indicator
|
| 240 |
+
if memory_context:
|
| 241 |
+
mem_texts = []
|
| 242 |
+
for m in memory_context:
|
| 243 |
+
if isinstance(m, str):
|
| 244 |
+
mem_texts.append(m[:100])
|
| 245 |
+
elif isinstance(m, dict):
|
| 246 |
+
t = m.get("memory") or m.get("text") or m.get("content") or ""
|
| 247 |
+
mem_texts.append(t[:100])
|
| 248 |
citations.append(Citation(
|
| 249 |
report_id=None,
|
| 250 |
+
metric_name="User Memory (Mem0)",
|
| 251 |
+
value=f"{len(memory_context)} memories referenced",
|
| 252 |
+
excerpt="; ".join(mem_texts)[:300],
|
| 253 |
))
|
| 254 |
+
|
| 255 |
+
# Graph source indicator
|
| 256 |
+
if graph_context:
|
| 257 |
+
citations.append(Citation(
|
| 258 |
+
report_id=None,
|
| 259 |
+
metric_name="Medical Knowledge Graph",
|
| 260 |
+
value=f"{len(graph_context)} graph facts referenced",
|
| 261 |
+
excerpt="; ".join(str(f)[:80] for f in graph_context)[:300],
|
| 262 |
+
))
|
| 263 |
+
|
| 264 |
return citations
|
| 265 |
|
| 266 |
async def get_session_history(
|
|
@@ -604,10 +604,12 @@ class DocumentProcessingService:
|
|
| 604 |
|
| 605 |
if report:
|
| 606 |
graph_service = get_graph_service()
|
|
|
|
| 607 |
|
| 608 |
# Add episode about the upload
|
| 609 |
-
await graph_service.
|
| 610 |
-
|
|
|
|
| 611 |
source="document_upload",
|
| 612 |
timestamp=report.uploaded_at.isoformat() if report.uploaded_at else datetime.utcnow().isoformat()
|
| 613 |
)
|
|
@@ -619,19 +621,93 @@ class DocumentProcessingService:
|
|
| 619 |
observations = obs_result.scalars().all()
|
| 620 |
|
| 621 |
if observations:
|
| 622 |
-
#
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
obs_texts.append(
|
| 627 |
-
f"{obs.metric_name}: {obs.value} {obs.unit}
|
|
|
|
| 628 |
)
|
| 629 |
|
| 630 |
-
await graph_service.
|
| 631 |
-
|
|
|
|
| 632 |
source=f"report_{report.filename}"
|
| 633 |
)
|
| 634 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
logger.info(f"Background sync complete for report {report_id}")
|
| 636 |
|
| 637 |
except Exception as e:
|
|
|
|
| 604 |
|
| 605 |
if report:
|
| 606 |
graph_service = get_graph_service()
|
| 607 |
+
memory_service = get_memory_service()
|
| 608 |
|
| 609 |
# Add episode about the upload
|
| 610 |
+
await graph_service.add_user_episode(
|
| 611 |
+
user_id=str(self.user.id),
|
| 612 |
+
content=f"uploaded medical report: {report.filename} (Type: {report.doc_type})",
|
| 613 |
source="document_upload",
|
| 614 |
timestamp=report.uploaded_at.isoformat() if report.uploaded_at else datetime.utcnow().isoformat()
|
| 615 |
)
|
|
|
|
| 621 |
observations = obs_result.scalars().all()
|
| 622 |
|
| 623 |
if observations:
|
| 624 |
+
# -------------------------------------------------------
|
| 625 |
+
# Only sync *clinically relevant* data to the knowledge
|
| 626 |
+
# graph – abnormal findings plus a brief overall summary.
|
| 627 |
+
# Dumping every normal reading would bloat the graph and
|
| 628 |
+
# dilute the signal used by the recommendation engine.
|
| 629 |
+
# -------------------------------------------------------
|
| 630 |
+
abnormal_obs = [obs for obs in observations if obs.is_abnormal]
|
| 631 |
+
normal_count = len(observations) - len(abnormal_obs)
|
| 632 |
+
|
| 633 |
+
obs_texts = [
|
| 634 |
+
f"Report {report.filename}: {len(observations)} total observations, "
|
| 635 |
+
f"{len(abnormal_obs)} abnormal, {normal_count} normal"
|
| 636 |
+
]
|
| 637 |
+
for obs in abnormal_obs:
|
| 638 |
obs_texts.append(
|
| 639 |
+
f"{obs.metric_name}: {obs.value} {obs.unit} "
|
| 640 |
+
f"(flagged {obs.flag or 'abnormal'})"
|
| 641 |
)
|
| 642 |
|
| 643 |
+
await graph_service.add_user_facts(
|
| 644 |
+
user_id=str(self.user.id),
|
| 645 |
+
facts=obs_texts,
|
| 646 |
source=f"report_{report.filename}"
|
| 647 |
)
|
| 648 |
|
| 649 |
+
# Sync report-level inferences to Mem0 so the "Health Memory"
|
| 650 |
+
# reflects document findings as well.
|
| 651 |
+
if memory_service.is_available:
|
| 652 |
+
abnormal_observations = abnormal_obs # already computed above
|
| 653 |
+
# normal_count already calculated above
|
| 654 |
+
|
| 655 |
+
summary_parts = [
|
| 656 |
+
f"report {report.filename} processed",
|
| 657 |
+
f"{len(observations)} observations extracted",
|
| 658 |
+
f"{len(abnormal_observations)} abnormal and {normal_count} normal flags",
|
| 659 |
+
]
|
| 660 |
+
if report.doc_type:
|
| 661 |
+
summary_parts.append(f"document type {report.doc_type}")
|
| 662 |
+
report_summary = "; ".join(summary_parts)
|
| 663 |
+
|
| 664 |
+
summary_result = await memory_service.add(
|
| 665 |
+
content=f"report inference summary: {report_summary}",
|
| 666 |
+
user_id=str(self.user.id),
|
| 667 |
+
metadata={
|
| 668 |
+
"source": "report_inference",
|
| 669 |
+
"report_id": str(report.id),
|
| 670 |
+
"doc_type": report.doc_type,
|
| 671 |
+
},
|
| 672 |
+
)
|
| 673 |
+
if isinstance(summary_result, dict) and summary_result.get("error"):
|
| 674 |
+
logger.warning(
|
| 675 |
+
"Failed to sync report summary to Mem0 for report %s: %s",
|
| 676 |
+
report_id,
|
| 677 |
+
summary_result["error"],
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
# Store abnormal findings as a single combined memory
|
| 681 |
+
# instead of individual calls to avoid Groq TPM exhaustion.
|
| 682 |
+
if abnormal_observations:
|
| 683 |
+
abnormal_lines = []
|
| 684 |
+
for obs in abnormal_observations[:12]:
|
| 685 |
+
abnormal_lines.append(
|
| 686 |
+
f"{obs.metric_name} measured {obs.value} {obs.unit} "
|
| 687 |
+
f"flagged {obs.flag or 'abnormal'}"
|
| 688 |
+
)
|
| 689 |
+
combined_text = (
|
| 690 |
+
f"report inference abnormal findings from {report.filename}:\n- "
|
| 691 |
+
+ "\n- ".join(abnormal_lines)
|
| 692 |
+
)
|
| 693 |
+
abnormal_result = await memory_service.add(
|
| 694 |
+
content=combined_text,
|
| 695 |
+
user_id=str(self.user.id),
|
| 696 |
+
metadata={
|
| 697 |
+
"source": "report_inference",
|
| 698 |
+
"report_id": str(report.id),
|
| 699 |
+
"kind": "abnormal_findings_batch",
|
| 700 |
+
"abnormal_count": len(abnormal_observations),
|
| 701 |
+
},
|
| 702 |
+
)
|
| 703 |
+
if isinstance(abnormal_result, dict) and abnormal_result.get("error"):
|
| 704 |
+
logger.warning(
|
| 705 |
+
"Failed to sync abnormal observations to Mem0 "
|
| 706 |
+
"(report=%s): %s",
|
| 707 |
+
report_id,
|
| 708 |
+
abnormal_result["error"],
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
logger.info(f"Background sync complete for report {report_id}")
|
| 712 |
|
| 713 |
except Exception as e:
|
|
@@ -6,17 +6,20 @@ Integrates:
|
|
| 6 |
- Lab report parsing
|
| 7 |
- Observation storage
|
| 8 |
- WebSocket event emission
|
|
|
|
| 9 |
"""
|
| 10 |
import logging
|
| 11 |
import os
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
-
from typing import Optional
|
| 14 |
from datetime import datetime
|
| 15 |
from uuid import UUID
|
| 16 |
|
| 17 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
from sqlalchemy import select
|
| 19 |
|
|
|
|
| 20 |
from app.models import Report, Observation, ReportStatus, ObservationType
|
| 21 |
from app.services.pdf_extractor import PDFExtractor
|
| 22 |
from app.services.lab_parser import LabParser
|
|
@@ -189,6 +192,10 @@ class EnhancedReportService:
|
|
| 189 |
|
| 190 |
# Trigger health index recomputation
|
| 191 |
await self._recompute_health_index_and_emit(user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
logger.info(f"Report {report_id} processing complete with {observation_count} observations")
|
| 194 |
|
|
@@ -239,3 +246,162 @@ class EnhancedReportService:
|
|
| 239 |
|
| 240 |
except Exception as e:
|
| 241 |
logger.exception(f"Error computing health index for user {user_id}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
- Lab report parsing
|
| 7 |
- Observation storage
|
| 8 |
- WebSocket event emission
|
| 9 |
+
- Memory / Knowledge Graph / RAG sync
|
| 10 |
"""
|
| 11 |
import logging
|
| 12 |
import os
|
| 13 |
+
import asyncio
|
| 14 |
from pathlib import Path
|
| 15 |
+
from typing import Optional, List
|
| 16 |
from datetime import datetime
|
| 17 |
from uuid import UUID
|
| 18 |
|
| 19 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 20 |
from sqlalchemy import select
|
| 21 |
|
| 22 |
+
from app.db import async_session_maker
|
| 23 |
from app.models import Report, Observation, ReportStatus, ObservationType
|
| 24 |
from app.services.pdf_extractor import PDFExtractor
|
| 25 |
from app.services.lab_parser import LabParser
|
|
|
|
| 192 |
|
| 193 |
# Trigger health index recomputation
|
| 194 |
await self._recompute_health_index_and_emit(user_id)
|
| 195 |
+
|
| 196 |
+
# Sync to Memory (Mem0), Knowledge Graph (Neo4j), and RAG (ChromaDB)
|
| 197 |
+
# in the background so the endpoint doesn't block.
|
| 198 |
+
asyncio.create_task(self._sync_to_memory_and_graph(report_id, user_id))
|
| 199 |
|
| 200 |
logger.info(f"Report {report_id} processing complete with {observation_count} observations")
|
| 201 |
|
|
|
|
| 246 |
|
| 247 |
except Exception as e:
|
| 248 |
logger.exception(f"Error computing health index for user {user_id}: {e}")
|
| 249 |
+
|
| 250 |
+
# ------------------------------------------------------------------
|
| 251 |
+
# Memory / Knowledge Graph / RAG sync (background task)
|
| 252 |
+
# ------------------------------------------------------------------
|
| 253 |
+
|
| 254 |
+
async def _sync_to_memory_and_graph(self, report_id: UUID, user_id: UUID):
|
| 255 |
+
"""
|
| 256 |
+
Background task to sync processed report data to:
|
| 257 |
+
1. RAG (ChromaDB) – for retrieval-augmented generation
|
| 258 |
+
2. Knowledge Graph (Neo4j/Graphiti) – abnormal findings only
|
| 259 |
+
3. Memory (Mem0) – report summary + abnormal observations
|
| 260 |
+
|
| 261 |
+
This bridges the gap between the active upload pipeline
|
| 262 |
+
(EnhancedReportService) and the intelligence layers.
|
| 263 |
+
"""
|
| 264 |
+
from app.services.memory_service import get_memory_service
|
| 265 |
+
from app.services.graph_service import get_graph_service
|
| 266 |
+
from app.services.rag_service import get_rag_service
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
logger.info("Starting background memory/graph/RAG sync for report %s", report_id)
|
| 270 |
+
|
| 271 |
+
# Use a fresh session so we don't interfere with the caller's session.
|
| 272 |
+
async with async_session_maker() as session:
|
| 273 |
+
# ---- 1. RAG sync (ChromaDB) ----
|
| 274 |
+
try:
|
| 275 |
+
rag_service = get_rag_service()
|
| 276 |
+
await rag_service.sync_user_reports(user_id, session)
|
| 277 |
+
await rag_service.sync_user_observations(user_id, session)
|
| 278 |
+
logger.info("RAG sync complete for report %s", report_id)
|
| 279 |
+
except Exception as e:
|
| 280 |
+
logger.warning("RAG sync failed for report %s: %s", report_id, e)
|
| 281 |
+
|
| 282 |
+
# ---- Fetch report + observations ----
|
| 283 |
+
result = await session.execute(
|
| 284 |
+
select(Report).where(Report.id == report_id)
|
| 285 |
+
)
|
| 286 |
+
report = result.scalar_one_or_none()
|
| 287 |
+
if not report:
|
| 288 |
+
logger.warning("Report %s not found during sync", report_id)
|
| 289 |
+
return
|
| 290 |
+
|
| 291 |
+
obs_result = await session.execute(
|
| 292 |
+
select(Observation).where(Observation.report_id == report_id)
|
| 293 |
+
)
|
| 294 |
+
observations: List[Observation] = list(obs_result.scalars().all())
|
| 295 |
+
|
| 296 |
+
if not observations:
|
| 297 |
+
logger.info("No observations for report %s – skipping graph/memory sync", report_id)
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
abnormal_obs = [obs for obs in observations if obs.is_abnormal]
|
| 301 |
+
normal_count = len(observations) - len(abnormal_obs)
|
| 302 |
+
uid = str(user_id)
|
| 303 |
+
|
| 304 |
+
# ---- 2. Knowledge Graph sync (Neo4j) — abnormal only ----
|
| 305 |
+
graph_service = get_graph_service()
|
| 306 |
+
if graph_service.client is not None:
|
| 307 |
+
try:
|
| 308 |
+
# Episode about the upload
|
| 309 |
+
await graph_service.add_user_episode(
|
| 310 |
+
user_id=uid,
|
| 311 |
+
content=(
|
| 312 |
+
f"uploaded medical report: {report.filename} "
|
| 313 |
+
f"(Type: {report.doc_type or 'unknown'})"
|
| 314 |
+
),
|
| 315 |
+
source="document_upload",
|
| 316 |
+
timestamp=(
|
| 317 |
+
report.uploaded_at.isoformat()
|
| 318 |
+
if report.uploaded_at
|
| 319 |
+
else datetime.utcnow().isoformat()
|
| 320 |
+
),
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Only clinically relevant (abnormal) observations
|
| 324 |
+
obs_texts = [
|
| 325 |
+
f"Report {report.filename}: {len(observations)} total observations, "
|
| 326 |
+
f"{len(abnormal_obs)} abnormal, {normal_count} normal"
|
| 327 |
+
]
|
| 328 |
+
for obs in abnormal_obs:
|
| 329 |
+
obs_texts.append(
|
| 330 |
+
f"{obs.metric_name}: {obs.value} {obs.unit} "
|
| 331 |
+
f"(flagged {obs.flag or 'abnormal'})"
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
await graph_service.add_user_facts(
|
| 335 |
+
user_id=uid,
|
| 336 |
+
facts=obs_texts,
|
| 337 |
+
source=f"report_{report.filename}",
|
| 338 |
+
)
|
| 339 |
+
logger.info(
|
| 340 |
+
"Graph sync complete for report %s (%d abnormal facts)",
|
| 341 |
+
report_id, len(abnormal_obs),
|
| 342 |
+
)
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.warning("Graph sync failed for report %s: %s", report_id, e)
|
| 345 |
+
else:
|
| 346 |
+
logger.debug("Graph service unavailable – skipping graph sync for report %s", report_id)
|
| 347 |
+
|
| 348 |
+
# ---- 3. Memory sync (Mem0) — summary + abnormal findings ----
|
| 349 |
+
memory_service = get_memory_service()
|
| 350 |
+
if memory_service.is_available:
|
| 351 |
+
try:
|
| 352 |
+
# Report-level summary
|
| 353 |
+
summary_parts = [
|
| 354 |
+
f"report {report.filename} processed",
|
| 355 |
+
f"{len(observations)} observations extracted",
|
| 356 |
+
f"{len(abnormal_obs)} abnormal and {normal_count} normal flags",
|
| 357 |
+
]
|
| 358 |
+
if report.doc_type:
|
| 359 |
+
summary_parts.append(f"document type {report.doc_type}")
|
| 360 |
+
report_summary = "; ".join(summary_parts)
|
| 361 |
+
|
| 362 |
+
await memory_service.add(
|
| 363 |
+
content=f"report inference summary: {report_summary}",
|
| 364 |
+
user_id=uid,
|
| 365 |
+
metadata={
|
| 366 |
+
"source": "report_inference",
|
| 367 |
+
"report_id": str(report_id),
|
| 368 |
+
"doc_type": report.doc_type,
|
| 369 |
+
},
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
# Batch abnormal findings into one memory
|
| 373 |
+
if abnormal_obs:
|
| 374 |
+
abnormal_lines = []
|
| 375 |
+
for obs in abnormal_obs[:12]:
|
| 376 |
+
abnormal_lines.append(
|
| 377 |
+
f"{obs.metric_name} measured {obs.value} {obs.unit} "
|
| 378 |
+
f"flagged {obs.flag or 'abnormal'}"
|
| 379 |
+
)
|
| 380 |
+
combined = (
|
| 381 |
+
f"report inference abnormal findings from {report.filename}:\n- "
|
| 382 |
+
+ "\n- ".join(abnormal_lines)
|
| 383 |
+
)
|
| 384 |
+
await memory_service.add(
|
| 385 |
+
content=combined,
|
| 386 |
+
user_id=uid,
|
| 387 |
+
metadata={
|
| 388 |
+
"source": "report_inference",
|
| 389 |
+
"report_id": str(report_id),
|
| 390 |
+
"kind": "abnormal_findings_batch",
|
| 391 |
+
"abnormal_count": len(abnormal_obs),
|
| 392 |
+
},
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
logger.info(
|
| 396 |
+
"Memory sync complete for report %s (%d abnormal findings)",
|
| 397 |
+
report_id, len(abnormal_obs),
|
| 398 |
+
)
|
| 399 |
+
except Exception as e:
|
| 400 |
+
logger.warning("Memory sync failed for report %s: %s", report_id, e)
|
| 401 |
+
else:
|
| 402 |
+
logger.debug("Memory service unavailable – skipping Mem0 sync for report %s", report_id)
|
| 403 |
+
|
| 404 |
+
logger.info("Background sync complete for report %s", report_id)
|
| 405 |
+
|
| 406 |
+
except Exception as e:
|
| 407 |
+
logger.error("Background sync failed for report %s: %s", report_id, e)
|
|
@@ -1,16 +1,23 @@
|
|
| 1 |
import logging
|
| 2 |
import asyncio
|
|
|
|
|
|
|
| 3 |
from typing import List, Dict, Any, Optional
|
| 4 |
|
| 5 |
try:
|
| 6 |
from graphiti_core import Graphiti
|
| 7 |
from graphiti_core.llm_client import OpenAIClient, LLMConfig
|
|
|
|
|
|
|
| 8 |
GRAPHITI_AVAILABLE = True
|
| 9 |
except ImportError:
|
| 10 |
GRAPHITI_AVAILABLE = False
|
| 11 |
Graphiti = None
|
| 12 |
OpenAIClient = None
|
| 13 |
LLMConfig = None
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from app.settings import settings
|
| 16 |
|
|
@@ -40,6 +47,16 @@ class GraphService:
|
|
| 40 |
self._initialized = True
|
| 41 |
logger.info("GraphService initialized (lazy loading client)")
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
async def initialize(self):
|
| 44 |
"""
|
| 45 |
Initialize the Neo4j driver and Graphiti client asynchronously.
|
|
@@ -53,28 +70,64 @@ class GraphService:
|
|
| 53 |
return
|
| 54 |
|
| 55 |
try:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
llm_client = OpenAIClient(
|
| 68 |
-
config=llm_config
|
|
|
|
| 69 |
)
|
| 70 |
|
| 71 |
-
# 2. Initialize
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
# Pass uri, user, password directly - Graphiti creates the driver internally
|
| 73 |
self.client = Graphiti(
|
| 74 |
uri=settings.NEO4J_URI,
|
| 75 |
user=settings.NEO4J_USER,
|
| 76 |
password=settings.NEO4J_PASSWORD,
|
| 77 |
-
llm_client=llm_client
|
|
|
|
|
|
|
| 78 |
)
|
| 79 |
|
| 80 |
# Build indices (this is an async operation)
|
|
@@ -87,7 +140,51 @@ class GraphService:
|
|
| 87 |
# Service will be degraded (graph features won't work)
|
| 88 |
self.client = None
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
"""
|
| 92 |
Add a new episode to the knowledge graph.
|
| 93 |
|
|
@@ -100,16 +197,17 @@ class GraphService:
|
|
| 100 |
logger.warning("GraphService not initialized, skipping add_episode")
|
| 101 |
return
|
| 102 |
|
| 103 |
-
def _sync_add():
|
| 104 |
-
self.client.add_episode(
|
| 105 |
-
name=f"Episode from {source}",
|
| 106 |
-
episode_body=content,
|
| 107 |
-
source_description=source,
|
| 108 |
-
reference_time=timestamp
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
try:
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
logger.info(f"Added episode to graph from source: {source}")
|
| 114 |
except Exception as e:
|
| 115 |
logger.error(f"Error adding episode to graph: {e}")
|
|
@@ -126,18 +224,73 @@ class GraphService:
|
|
| 126 |
content = "Medical System Observations:\n- " + "\n- ".join(observations)
|
| 127 |
await self.add_episode(content, source=source)
|
| 128 |
|
| 129 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"""
|
| 131 |
Search the knowledge graph for relevant facts.
|
| 132 |
"""
|
| 133 |
if not self.client:
|
| 134 |
return []
|
| 135 |
|
| 136 |
-
|
| 137 |
-
# Graphiti
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
# Format results into strings
|
| 141 |
formatted_results = []
|
| 142 |
if results and hasattr(results, 'edges'):
|
| 143 |
for edge in results.edges:
|
|
@@ -145,30 +298,64 @@ class GraphService:
|
|
| 145 |
f"{edge.source_node.name} -> {edge.relation} -> {edge.target_node.name}"
|
| 146 |
)
|
| 147 |
elif isinstance(results, list):
|
| 148 |
-
# Handle case where it might return a list directly
|
| 149 |
for item in results:
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
return formatted_results
|
| 153 |
-
|
| 154 |
-
try:
|
| 155 |
-
return await asyncio.to_thread(_sync_search)
|
| 156 |
except Exception as e:
|
| 157 |
logger.error(f"Error searching graph: {e}")
|
| 158 |
return []
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
async def close(self):
|
| 161 |
"""Close the Neo4j driver"""
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
# Global instance
|
| 174 |
graph_service = GraphService()
|
|
|
|
| 1 |
import logging
|
| 2 |
import asyncio
|
| 3 |
+
import inspect
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
from typing import List, Dict, Any, Optional
|
| 6 |
|
| 7 |
try:
|
| 8 |
from graphiti_core import Graphiti
|
| 9 |
from graphiti_core.llm_client import OpenAIClient, LLMConfig
|
| 10 |
+
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
|
| 11 |
+
from graphiti_core.cross_encoder.openai_reranker_client import OpenAIRerankerClient
|
| 12 |
GRAPHITI_AVAILABLE = True
|
| 13 |
except ImportError:
|
| 14 |
GRAPHITI_AVAILABLE = False
|
| 15 |
Graphiti = None
|
| 16 |
OpenAIClient = None
|
| 17 |
LLMConfig = None
|
| 18 |
+
OpenAIEmbedder = None
|
| 19 |
+
OpenAIEmbedderConfig = None
|
| 20 |
+
OpenAIRerankerClient = None
|
| 21 |
|
| 22 |
from app.settings import settings
|
| 23 |
|
|
|
|
| 47 |
self._initialized = True
|
| 48 |
logger.info("GraphService initialized (lazy loading client)")
|
| 49 |
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _user_node_label(user_id: str) -> str:
|
| 52 |
+
"""Deterministic user node label used for graph tenant scoping."""
|
| 53 |
+
return f"User_{user_id}"
|
| 54 |
+
|
| 55 |
+
@classmethod
|
| 56 |
+
def _is_user_scoped_result(cls, result: str, user_id: str) -> bool:
|
| 57 |
+
label = cls._user_node_label(user_id)
|
| 58 |
+
return label.lower() in result.lower()
|
| 59 |
+
|
| 60 |
async def initialize(self):
|
| 61 |
"""
|
| 62 |
Initialize the Neo4j driver and Graphiti client asynchronously.
|
|
|
|
| 70 |
return
|
| 71 |
|
| 72 |
try:
|
| 73 |
+
use_groq = self._should_use_groq_llm()
|
| 74 |
+
if use_groq:
|
| 75 |
+
logger.info("Initializing Graphiti with Neo4j and Groq (OpenAI-compatible)...")
|
| 76 |
+
llm_config = LLMConfig(
|
| 77 |
+
api_key=settings.groq_api_key,
|
| 78 |
+
base_url=settings.groq_api_base,
|
| 79 |
+
model=settings.GRAPHITI_GROQ_MODEL,
|
| 80 |
+
small_model=settings.GRAPHITI_GROQ_MODEL,
|
| 81 |
+
temperature=0.1,
|
| 82 |
+
max_tokens=4096,
|
| 83 |
+
)
|
| 84 |
+
else:
|
| 85 |
+
logger.info("Initializing Graphiti with Neo4j and Ollama...")
|
| 86 |
+
# Ollama via OpenAI-compatible endpoint.
|
| 87 |
+
llm_config = LLMConfig(
|
| 88 |
+
api_key="ollama", # Dummy key for Ollama
|
| 89 |
+
base_url=f"{settings.OLLAMA_BASE_URL}/v1",
|
| 90 |
+
model=settings.OLLAMA_MODEL,
|
| 91 |
+
small_model=settings.OLLAMA_MODEL,
|
| 92 |
+
temperature=0.1,
|
| 93 |
+
max_tokens=4096,
|
| 94 |
+
)
|
| 95 |
|
| 96 |
llm_client = OpenAIClient(
|
| 97 |
+
config=llm_config,
|
| 98 |
+
max_tokens=2048,
|
| 99 |
)
|
| 100 |
|
| 101 |
+
# 2. Initialize embedder explicitly to avoid default OpenAI embedder
|
| 102 |
+
# requiring OPENAI_API_KEY. Use Ollama's OpenAI-compatible embeddings API.
|
| 103 |
+
embedder_client = None
|
| 104 |
+
if OpenAIEmbedder and OpenAIEmbedderConfig:
|
| 105 |
+
embedder_client = OpenAIEmbedder(
|
| 106 |
+
config=OpenAIEmbedderConfig(
|
| 107 |
+
embedding_model=settings.MEM0_EMBED_MODEL,
|
| 108 |
+
embedding_dim=768, # `nomic-embed-text` dimension
|
| 109 |
+
api_key="ollama",
|
| 110 |
+
base_url=f"{settings.OLLAMA_BASE_URL}/v1",
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
else:
|
| 114 |
+
logger.warning(
|
| 115 |
+
"Graphiti OpenAIEmbedder not available; falling back to Graphiti default embedder"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
cross_encoder_client = None
|
| 119 |
+
if OpenAIRerankerClient:
|
| 120 |
+
cross_encoder_client = OpenAIRerankerClient(config=llm_config)
|
| 121 |
+
|
| 122 |
+
# 3. Initialize Graphiti Client with Neo4j connection
|
| 123 |
# Pass uri, user, password directly - Graphiti creates the driver internally
|
| 124 |
self.client = Graphiti(
|
| 125 |
uri=settings.NEO4J_URI,
|
| 126 |
user=settings.NEO4J_USER,
|
| 127 |
password=settings.NEO4J_PASSWORD,
|
| 128 |
+
llm_client=llm_client,
|
| 129 |
+
embedder=embedder_client,
|
| 130 |
+
cross_encoder=cross_encoder_client,
|
| 131 |
)
|
| 132 |
|
| 133 |
# Build indices (this is an async operation)
|
|
|
|
| 140 |
# Service will be degraded (graph features won't work)
|
| 141 |
self.client = None
|
| 142 |
|
| 143 |
+
@staticmethod
|
| 144 |
+
def _should_use_groq_llm() -> bool:
|
| 145 |
+
"""
|
| 146 |
+
Prefer Groq whenever an API key is configured.
|
| 147 |
+
Ollama remains fallback when no Groq key is present.
|
| 148 |
+
"""
|
| 149 |
+
return bool(settings.groq_api_key)
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
async def _await_if_needed(value: Any) -> Any:
|
| 153 |
+
"""
|
| 154 |
+
Graphiti SDK methods differ by version: some are sync, some are async.
|
| 155 |
+
Await only when the returned object is awaitable.
|
| 156 |
+
"""
|
| 157 |
+
if inspect.isawaitable(value):
|
| 158 |
+
return await value
|
| 159 |
+
return value
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def _coerce_reference_time(timestamp: Optional[str]) -> datetime:
|
| 163 |
+
"""
|
| 164 |
+
Graphiti requires a datetime reference_time. Accept optional ISO string
|
| 165 |
+
from callers and default to current UTC when not provided.
|
| 166 |
+
"""
|
| 167 |
+
if isinstance(timestamp, str) and timestamp.strip():
|
| 168 |
+
raw = timestamp.strip()
|
| 169 |
+
# Support trailing Z timestamps.
|
| 170 |
+
if raw.endswith("Z"):
|
| 171 |
+
raw = raw[:-1] + "+00:00"
|
| 172 |
+
try:
|
| 173 |
+
parsed = datetime.fromisoformat(raw)
|
| 174 |
+
if parsed.tzinfo is None:
|
| 175 |
+
return parsed.replace(tzinfo=timezone.utc)
|
| 176 |
+
return parsed
|
| 177 |
+
except Exception:
|
| 178 |
+
logger.warning("Invalid timestamp passed to GraphService.add_episode: %s", timestamp)
|
| 179 |
+
return datetime.now(timezone.utc)
|
| 180 |
+
|
| 181 |
+
async def add_episode(
|
| 182 |
+
self,
|
| 183 |
+
content: str,
|
| 184 |
+
source: str = "user_input",
|
| 185 |
+
timestamp: Optional[str] = None,
|
| 186 |
+
group_id: Optional[str] = None,
|
| 187 |
+
):
|
| 188 |
"""
|
| 189 |
Add a new episode to the knowledge graph.
|
| 190 |
|
|
|
|
| 197 |
logger.warning("GraphService not initialized, skipping add_episode")
|
| 198 |
return
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
try:
|
| 201 |
+
reference_time = self._coerce_reference_time(timestamp)
|
| 202 |
+
await self._await_if_needed(
|
| 203 |
+
self.client.add_episode(
|
| 204 |
+
name=f"Episode from {source}",
|
| 205 |
+
episode_body=content,
|
| 206 |
+
source_description=source,
|
| 207 |
+
reference_time=reference_time,
|
| 208 |
+
group_id=group_id,
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
logger.info(f"Added episode to graph from source: {source}")
|
| 212 |
except Exception as e:
|
| 213 |
logger.error(f"Error adding episode to graph: {e}")
|
|
|
|
| 224 |
content = "Medical System Observations:\n- " + "\n- ".join(observations)
|
| 225 |
await self.add_episode(content, source=source)
|
| 226 |
|
| 227 |
+
async def add_user_episode(
|
| 228 |
+
self,
|
| 229 |
+
user_id: str,
|
| 230 |
+
content: str,
|
| 231 |
+
source: str = "user_input",
|
| 232 |
+
timestamp: Optional[str] = None,
|
| 233 |
+
) -> None:
|
| 234 |
+
"""
|
| 235 |
+
Add an episode that is explicitly scoped to a single user.
|
| 236 |
+
"""
|
| 237 |
+
user_label = self._user_node_label(user_id)
|
| 238 |
+
scoped_content = f"[{user_label}] {content}"
|
| 239 |
+
scoped_source = f"{source}|{user_label}"
|
| 240 |
+
await self.add_episode(
|
| 241 |
+
scoped_content,
|
| 242 |
+
source=scoped_source,
|
| 243 |
+
timestamp=timestamp,
|
| 244 |
+
group_id=user_id,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
async def add_user_facts(
|
| 248 |
+
self,
|
| 249 |
+
user_id: str,
|
| 250 |
+
facts: List[str],
|
| 251 |
+
source: str = "profile_sync",
|
| 252 |
+
timestamp: Optional[str] = None,
|
| 253 |
+
) -> bool:
|
| 254 |
+
"""
|
| 255 |
+
Add a batch of user-scoped facts as one episode.
|
| 256 |
+
"""
|
| 257 |
+
if not facts:
|
| 258 |
+
return False
|
| 259 |
+
user_label = self._user_node_label(user_id)
|
| 260 |
+
content = f"{user_label} profile facts:\n- " + "\n- ".join(facts)
|
| 261 |
+
await self.add_user_episode(
|
| 262 |
+
user_id=user_id,
|
| 263 |
+
content=content,
|
| 264 |
+
source=source,
|
| 265 |
+
timestamp=timestamp,
|
| 266 |
+
)
|
| 267 |
+
return True
|
| 268 |
+
|
| 269 |
+
async def search(
|
| 270 |
+
self,
|
| 271 |
+
query: str,
|
| 272 |
+
limit: int = 10,
|
| 273 |
+
group_ids: Optional[List[str]] = None,
|
| 274 |
+
) -> List[str]:
|
| 275 |
"""
|
| 276 |
Search the knowledge graph for relevant facts.
|
| 277 |
"""
|
| 278 |
if not self.client:
|
| 279 |
return []
|
| 280 |
|
| 281 |
+
try:
|
| 282 |
+
# Graphiti parameter name changed across versions (`limit` vs `num_results`).
|
| 283 |
+
search_kwargs: Dict[str, Any] = {}
|
| 284 |
+
if group_ids:
|
| 285 |
+
search_kwargs["group_ids"] = group_ids
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
raw_results = self.client.search(query, num_results=limit, **search_kwargs)
|
| 289 |
+
except TypeError:
|
| 290 |
+
raw_results = self.client.search(query, limit=limit, **search_kwargs)
|
| 291 |
+
|
| 292 |
+
results = await self._await_if_needed(raw_results)
|
| 293 |
|
|
|
|
| 294 |
formatted_results = []
|
| 295 |
if results and hasattr(results, 'edges'):
|
| 296 |
for edge in results.edges:
|
|
|
|
| 298 |
f"{edge.source_node.name} -> {edge.relation} -> {edge.target_node.name}"
|
| 299 |
)
|
| 300 |
elif isinstance(results, list):
|
|
|
|
| 301 |
for item in results:
|
| 302 |
+
# Newer Graphiti search returns EntityEdge objects (no source/target names).
|
| 303 |
+
if hasattr(item, "fact") and hasattr(item, "name"):
|
| 304 |
+
relation = getattr(item, "name", "states") or "states"
|
| 305 |
+
fact = getattr(item, "fact", None)
|
| 306 |
+
if fact:
|
| 307 |
+
formatted_results.append(f"System -> {relation} -> {fact}")
|
| 308 |
+
continue
|
| 309 |
+
if (
|
| 310 |
+
hasattr(item, "source_node_uuid")
|
| 311 |
+
and hasattr(item, "target_node_uuid")
|
| 312 |
+
and hasattr(item, "name")
|
| 313 |
+
):
|
| 314 |
+
formatted_results.append(
|
| 315 |
+
f"{item.source_node_uuid} -> {item.name} -> {item.target_node_uuid}"
|
| 316 |
+
)
|
| 317 |
+
else:
|
| 318 |
+
formatted_results.append(str(item))
|
| 319 |
|
| 320 |
return formatted_results
|
|
|
|
|
|
|
|
|
|
| 321 |
except Exception as e:
|
| 322 |
logger.error(f"Error searching graph: {e}")
|
| 323 |
return []
|
| 324 |
|
| 325 |
+
async def search_user(self, user_id: str, query: str, limit: int = 10) -> List[str]:
|
| 326 |
+
"""
|
| 327 |
+
Search graph facts and return only facts scoped to a specific user.
|
| 328 |
+
|
| 329 |
+
Uses ``group_ids`` to scope the Graphiti query. A secondary client-
|
| 330 |
+
side filter removes any leaked cross-user data. If nothing passes
|
| 331 |
+
the filter, an empty list is returned (never unfiltered results).
|
| 332 |
+
"""
|
| 333 |
+
scoped_query = f"{self._user_node_label(user_id)} {query}".strip()
|
| 334 |
+
results = await self.search(scoped_query, limit=limit, group_ids=[user_id])
|
| 335 |
+
filtered = [item for item in results if self._is_user_scoped_result(item, user_id)]
|
| 336 |
+
return filtered[:limit]
|
| 337 |
+
|
| 338 |
async def close(self):
|
| 339 |
"""Close the Neo4j driver"""
|
| 340 |
+
if not self.client:
|
| 341 |
+
return
|
| 342 |
+
|
| 343 |
+
try:
|
| 344 |
+
# Newer Graphiti versions expose `close()` directly on client.
|
| 345 |
+
if hasattr(self.client, "close"):
|
| 346 |
+
await self._await_if_needed(self.client.close())
|
| 347 |
+
# Backward compatibility for older client shapes.
|
| 348 |
+
elif hasattr(self.client, "graph_driver"):
|
| 349 |
+
graph_driver = self.client.graph_driver
|
| 350 |
+
if hasattr(graph_driver, "close"):
|
| 351 |
+
await self._await_if_needed(graph_driver.close())
|
| 352 |
+
elif hasattr(graph_driver, "driver"):
|
| 353 |
+
await self._await_if_needed(graph_driver.driver.close())
|
| 354 |
+
elif hasattr(self.client, "driver") and hasattr(self.client.driver, "close"):
|
| 355 |
+
await self._await_if_needed(self.client.driver.close())
|
| 356 |
+
logger.info("Graphiti driver closed")
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logger.warning(f"Error closing graph driver: {e}")
|
| 359 |
|
| 360 |
# Global instance
|
| 361 |
graph_service = GraphService()
|
|
@@ -517,7 +517,8 @@ async def _gather_user_context(user_id: str, db: AsyncSession) -> Dict[str, Any]
|
|
| 517 |
return []
|
| 518 |
|
| 519 |
# Search for relevant medical knowledge
|
| 520 |
-
results = await graph_service.
|
|
|
|
| 521 |
query="health conditions medications recommendations risk factors",
|
| 522 |
limit=10
|
| 523 |
)
|
|
|
|
| 517 |
return []
|
| 518 |
|
| 519 |
# Search for relevant medical knowledge
|
| 520 |
+
results = await graph_service.search_user(
|
| 521 |
+
user_id=user_id,
|
| 522 |
query="health conditions medications recommendations risk factors",
|
| 523 |
limit=10
|
| 524 |
)
|
|
@@ -1,11 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
-
LLM Service -
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
import asyncio
|
| 8 |
-
from typing import AsyncGenerator, Optional
|
| 9 |
import logging
|
| 10 |
|
| 11 |
from app.settings import settings
|
|
@@ -17,8 +22,10 @@ logger = logging.getLogger(__name__)
|
|
| 17 |
# inside Docker).
|
| 18 |
_resolved_ollama_base_url: Optional[str] = None
|
| 19 |
|
| 20 |
-
#
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
Your role is to help users understand their health data, lab results, and medical information.
|
| 23 |
|
| 24 |
Guidelines:
|
|
@@ -29,6 +36,14 @@ Guidelines:
|
|
| 29 |
- Never diagnose conditions - only explain what the data indicates
|
| 30 |
- Be supportive and non-alarmist while being honest about concerning values
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
You have access to the user's personal health data provided in the context below.
|
| 33 |
Answer based on their specific data when available."""
|
| 34 |
|
|
@@ -36,200 +51,305 @@ Answer based on their specific data when available."""
|
|
| 36 |
class LLMService:
|
| 37 |
"""
|
| 38 |
LLM Service for generating responses with streaming support.
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
"""
|
| 43 |
-
|
| 44 |
def __init__(self):
|
| 45 |
self._ollama_available: Optional[bool] = None
|
| 46 |
self._gemini_client = None
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
async def check_ollama_health(self) -> bool:
|
| 49 |
"""Check if Ollama is available and the model is loaded."""
|
| 50 |
global _resolved_ollama_base_url
|
| 51 |
|
| 52 |
-
# Prefer a previously successful URL if we discovered one.
|
| 53 |
base_url = _resolved_ollama_base_url or settings.OLLAMA_BASE_URL
|
| 54 |
|
| 55 |
try:
|
| 56 |
import ollama
|
| 57 |
client = ollama.AsyncClient(host=base_url)
|
| 58 |
-
|
| 59 |
-
models = await asyncio.wait_for(
|
| 60 |
-
client.list(),
|
| 61 |
-
timeout=5.0
|
| 62 |
-
)
|
| 63 |
-
# We only care that the server is reachable. The model might not be
|
| 64 |
-
# pulled yet; Ollama will automatically pull it on first use.
|
| 65 |
-
model_names = [m.get('name', '') for m in models.get('models', [])]
|
| 66 |
-
if settings.OLLAMA_MODEL in model_names or any(
|
| 67 |
-
settings.OLLAMA_MODEL.split(':')[0] in m for m in model_names
|
| 68 |
-
):
|
| 69 |
-
logger.info(f"Ollama available with model {settings.OLLAMA_MODEL} at {base_url}")
|
| 70 |
-
else:
|
| 71 |
-
logger.warning(
|
| 72 |
-
"Ollama reachable at %s but model %s not found. "
|
| 73 |
-
"Available models: %s. The server will attempt to pull the "
|
| 74 |
-
"model on first use.",
|
| 75 |
-
base_url,
|
| 76 |
-
settings.OLLAMA_MODEL,
|
| 77 |
-
model_names,
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
_resolved_ollama_base_url = base_url
|
|
|
|
| 81 |
return True
|
| 82 |
except Exception as e:
|
| 83 |
-
logger.warning(
|
| 84 |
|
| 85 |
-
# Common misconfig: using http://localhost:11434 from inside Docker.
|
| 86 |
-
# If we see that, try host.docker.internal automatically so the
|
| 87 |
-
# container can reach the host's Ollama daemon.
|
| 88 |
if "localhost" in base_url or "127.0.0.1" in base_url:
|
| 89 |
fallback_url = base_url.replace("localhost", "host.docker.internal").replace(
|
| 90 |
"127.0.0.1", "host.docker.internal"
|
| 91 |
)
|
| 92 |
try:
|
| 93 |
import ollama
|
| 94 |
-
|
| 95 |
-
logger.info(
|
| 96 |
-
"Retrying Ollama health check using %s "
|
| 97 |
-
"(likely running from inside Docker).",
|
| 98 |
-
fallback_url,
|
| 99 |
-
)
|
| 100 |
client = ollama.AsyncClient(host=fallback_url)
|
| 101 |
await asyncio.wait_for(client.list(), timeout=5.0)
|
| 102 |
_resolved_ollama_base_url = fallback_url
|
| 103 |
logger.info("Ollama available at %s", fallback_url)
|
| 104 |
return True
|
| 105 |
except Exception as e2:
|
| 106 |
-
logger.warning(
|
| 107 |
-
"Fallback Ollama check at %s also failed: %s",
|
| 108 |
-
fallback_url,
|
| 109 |
-
e2,
|
| 110 |
-
)
|
| 111 |
|
| 112 |
return False
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
| 114 |
async def _ensure_provider(self) -> str:
|
| 115 |
-
"""
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if settings.USE_GEMINI_FALLBACK and settings.GEMINI_API_KEY:
|
| 118 |
-
logger.info("
|
| 119 |
return "gemini"
|
| 120 |
-
|
| 121 |
-
#
|
| 122 |
if self._ollama_available is None:
|
| 123 |
self._ollama_available = await self.check_ollama_health()
|
| 124 |
-
|
| 125 |
if self._ollama_available:
|
|
|
|
| 126 |
return "ollama"
|
| 127 |
-
|
| 128 |
-
# Make LLM optional: return a friendly message rather than hard-failing
|
| 129 |
-
# core app features when neither provider is configured.
|
| 130 |
return "disabled"
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 132 |
async def stream_generate(
|
| 133 |
self,
|
| 134 |
user_message: str,
|
| 135 |
context: str,
|
| 136 |
-
chat_history: Optional[list] = None
|
| 137 |
) -> AsyncGenerator[str, None]:
|
| 138 |
-
"""
|
| 139 |
-
Generate a streaming response from the LLM.
|
| 140 |
-
|
| 141 |
-
Args:
|
| 142 |
-
user_message: The user's question/message
|
| 143 |
-
context: Retrieved context from RAG (user's health data)
|
| 144 |
-
chat_history: Optional list of previous messages
|
| 145 |
-
|
| 146 |
-
Yields:
|
| 147 |
-
String tokens as they are generated
|
| 148 |
-
"""
|
| 149 |
provider = await self._ensure_provider()
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
if provider == "ollama":
|
| 155 |
-
async for token in self._stream_ollama(full_prompt):
|
| 156 |
yield token
|
| 157 |
elif provider == "gemini":
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
yield token
|
| 160 |
else:
|
| 161 |
yield (
|
| 162 |
"LLM is not configured. "
|
| 163 |
-
"
|
| 164 |
-
"or
|
| 165 |
)
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
def _build_prompt(
|
| 168 |
self,
|
| 169 |
user_message: str,
|
| 170 |
context: str,
|
| 171 |
-
chat_history: Optional[list] = None
|
| 172 |
) -> str:
|
| 173 |
-
"""Build
|
| 174 |
-
|
| 175 |
-
|
| 176 |
if context:
|
| 177 |
-
|
| 178 |
-
|
| 179 |
if chat_history:
|
| 180 |
-
|
| 181 |
-
for msg in chat_history[-10:]:
|
| 182 |
role = "User" if msg.get("role") == "user" else "Assistant"
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
async def _stream_ollama(self, prompt: str) -> AsyncGenerator[str, None]:
|
| 191 |
"""Stream response from Ollama."""
|
| 192 |
import ollama
|
| 193 |
-
|
| 194 |
-
# Use any resolved URL from health checks, falling back to settings.
|
| 195 |
host = _resolved_ollama_base_url or settings.OLLAMA_BASE_URL
|
| 196 |
client = ollama.AsyncClient(host=host)
|
| 197 |
-
|
| 198 |
try:
|
| 199 |
response = await client.chat(
|
| 200 |
model=settings.OLLAMA_MODEL,
|
| 201 |
messages=[{"role": "user", "content": prompt}],
|
| 202 |
stream=True,
|
| 203 |
-
options={
|
| 204 |
-
"temperature": 0.7,
|
| 205 |
-
"top_p": 0.9,
|
| 206 |
-
}
|
| 207 |
)
|
| 208 |
-
|
| 209 |
async for chunk in response:
|
| 210 |
if chunk.get("message", {}).get("content"):
|
| 211 |
yield chunk["message"]["content"]
|
| 212 |
-
|
| 213 |
except Exception as e:
|
| 214 |
-
logger.error(
|
| 215 |
-
# Mark as unavailable and try fallback
|
| 216 |
self._ollama_available = False
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
yield f"Error: Unable to generate response. {str(e)}"
|
| 223 |
-
|
| 224 |
async def _stream_gemini(self, prompt: str) -> AsyncGenerator[str, None]:
|
| 225 |
"""Stream response from Gemini API."""
|
| 226 |
try:
|
| 227 |
import google.generativeai as genai
|
| 228 |
-
|
| 229 |
if not self._gemini_client:
|
| 230 |
genai.configure(api_key=settings.GEMINI_API_KEY)
|
| 231 |
self._gemini_client = genai.GenerativeModel("gemini-flash-latest")
|
| 232 |
-
|
| 233 |
response = await asyncio.to_thread(
|
| 234 |
lambda: self._gemini_client.generate_content(
|
| 235 |
prompt,
|
|
@@ -238,35 +358,22 @@ class LLMService:
|
|
| 238 |
"temperature": 0.7,
|
| 239 |
"top_p": 0.9,
|
| 240 |
"max_output_tokens": 2048,
|
| 241 |
-
}
|
| 242 |
)
|
| 243 |
)
|
| 244 |
-
|
| 245 |
for chunk in response:
|
| 246 |
if chunk.text:
|
| 247 |
yield chunk.text
|
| 248 |
-
|
| 249 |
except Exception as e:
|
| 250 |
-
logger.error(
|
| 251 |
-
yield f"Error:
|
| 252 |
-
|
| 253 |
-
async def generate(
|
| 254 |
-
self,
|
| 255 |
-
user_message: str,
|
| 256 |
-
context: str,
|
| 257 |
-
chat_history: Optional[list] = None
|
| 258 |
-
) -> str:
|
| 259 |
-
"""
|
| 260 |
-
Generate a complete (non-streaming) response.
|
| 261 |
-
Useful for testing or when streaming is not needed.
|
| 262 |
-
"""
|
| 263 |
-
response_parts = []
|
| 264 |
-
async for token in self.stream_generate(user_message, context, chat_history):
|
| 265 |
-
response_parts.append(token)
|
| 266 |
-
return "".join(response_parts)
|
| 267 |
|
| 268 |
|
| 269 |
-
#
|
|
|
|
|
|
|
| 270 |
_llm_service: Optional[LLMService] = None
|
| 271 |
|
| 272 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
LLM Service - OpenRouter primary with multi-tier fallback
|
| 3 |
|
| 4 |
+
Provider priority:
|
| 5 |
+
1. OpenRouter (openrouter/pony-alpha)
|
| 6 |
+
2. OpenRouter (upstage/solar-pro-3:free) – free fallback
|
| 7 |
+
3. Gemini (gemini-flash-latest) – Google fallback
|
| 8 |
+
4. Ollama (MedGemma via GGUF) – local last resort
|
| 9 |
+
|
| 10 |
+
Uses the OpenAI Python SDK pointed at OpenRouter's OpenAI-compatible endpoint.
|
| 11 |
"""
|
| 12 |
import asyncio
|
| 13 |
+
from typing import AsyncGenerator, Optional, List, Dict, Any
|
| 14 |
import logging
|
| 15 |
|
| 16 |
from app.settings import settings
|
|
|
|
| 22 |
# inside Docker).
|
| 23 |
_resolved_ollama_base_url: Optional[str] = None
|
| 24 |
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# System prompt
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
MEDICAL_SYSTEM_PROMPT = """You are a knowledgeable and empathetic AI medical health assistant.
|
| 29 |
Your role is to help users understand their health data, lab results, and medical information.
|
| 30 |
|
| 31 |
Guidelines:
|
|
|
|
| 36 |
- Never diagnose conditions - only explain what the data indicates
|
| 37 |
- Be supportive and non-alarmist while being honest about concerning values
|
| 38 |
|
| 39 |
+
IMPORTANT - Source Attribution Rules:
|
| 40 |
+
When you use information from the context provided below, you MUST cite the source inline:
|
| 41 |
+
- For report data, say: "Based on your report '[filename]' ..." or "From your uploaded report ..."
|
| 42 |
+
- For memory/preferences, say: "From your health profile, I recall that ..." or "According to your stored preferences ..."
|
| 43 |
+
- For knowledge graph facts, say: "From the medical knowledge graph, I can see that ..." or "Based on your medical history graph ..."
|
| 44 |
+
- If combining multiple sources, mention each one.
|
| 45 |
+
- If you don't have relevant data, say so clearly.
|
| 46 |
+
|
| 47 |
You have access to the user's personal health data provided in the context below.
|
| 48 |
Answer based on their specific data when available."""
|
| 49 |
|
|
|
|
| 51 |
class LLMService:
|
| 52 |
"""
|
| 53 |
LLM Service for generating responses with streaming support.
|
| 54 |
+
|
| 55 |
+
Priority chain:
|
| 56 |
+
1. OpenRouter – pony-alpha (primary)
|
| 57 |
+
2. OpenRouter – solar-pro-3:free (fallback)
|
| 58 |
+
3. Gemini API (Google fallback)
|
| 59 |
+
4. Ollama (local last-resort)
|
| 60 |
"""
|
| 61 |
+
|
| 62 |
def __init__(self):
|
| 63 |
self._ollama_available: Optional[bool] = None
|
| 64 |
self._gemini_client = None
|
| 65 |
+
self._openrouter_client = None
|
| 66 |
+
|
| 67 |
+
# ------------------------------------------------------------------
|
| 68 |
+
# OpenRouter (OpenAI-compatible SDK)
|
| 69 |
+
# ------------------------------------------------------------------
|
| 70 |
+
def _get_openrouter_client(self):
|
| 71 |
+
"""Lazy-init the OpenAI client pointed at OpenRouter."""
|
| 72 |
+
if self._openrouter_client is None:
|
| 73 |
+
from openai import AsyncOpenAI
|
| 74 |
+
|
| 75 |
+
self._openrouter_client = AsyncOpenAI(
|
| 76 |
+
base_url=settings.OPENROUTER_BASE_URL,
|
| 77 |
+
api_key=settings.OPENROUTER_API_KEY,
|
| 78 |
+
default_headers={
|
| 79 |
+
"HTTP-Referer": settings.frontend_origin,
|
| 80 |
+
"X-Title": "Lumea Health Assistant",
|
| 81 |
+
},
|
| 82 |
+
timeout=settings.OPENROUTER_TIMEOUT,
|
| 83 |
+
)
|
| 84 |
+
return self._openrouter_client
|
| 85 |
+
|
| 86 |
+
# ------------------------------------------------------------------
|
| 87 |
+
# Ollama health check (kept for last-resort fallback)
|
| 88 |
+
# ------------------------------------------------------------------
|
| 89 |
async def check_ollama_health(self) -> bool:
|
| 90 |
"""Check if Ollama is available and the model is loaded."""
|
| 91 |
global _resolved_ollama_base_url
|
| 92 |
|
|
|
|
| 93 |
base_url = _resolved_ollama_base_url or settings.OLLAMA_BASE_URL
|
| 94 |
|
| 95 |
try:
|
| 96 |
import ollama
|
| 97 |
client = ollama.AsyncClient(host=base_url)
|
| 98 |
+
await asyncio.wait_for(client.list(), timeout=5.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
_resolved_ollama_base_url = base_url
|
| 100 |
+
logger.info("Ollama available at %s", base_url)
|
| 101 |
return True
|
| 102 |
except Exception as e:
|
| 103 |
+
logger.warning("Ollama not available at %s: %s", base_url, e)
|
| 104 |
|
|
|
|
|
|
|
|
|
|
| 105 |
if "localhost" in base_url or "127.0.0.1" in base_url:
|
| 106 |
fallback_url = base_url.replace("localhost", "host.docker.internal").replace(
|
| 107 |
"127.0.0.1", "host.docker.internal"
|
| 108 |
)
|
| 109 |
try:
|
| 110 |
import ollama
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
client = ollama.AsyncClient(host=fallback_url)
|
| 112 |
await asyncio.wait_for(client.list(), timeout=5.0)
|
| 113 |
_resolved_ollama_base_url = fallback_url
|
| 114 |
logger.info("Ollama available at %s", fallback_url)
|
| 115 |
return True
|
| 116 |
except Exception as e2:
|
| 117 |
+
logger.warning("Fallback Ollama check at %s also failed: %s", fallback_url, e2)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
return False
|
| 120 |
+
|
| 121 |
+
# ------------------------------------------------------------------
|
| 122 |
+
# Provider resolution
|
| 123 |
+
# ------------------------------------------------------------------
|
| 124 |
async def _ensure_provider(self) -> str:
|
| 125 |
+
"""
|
| 126 |
+
Determine which provider to use.
|
| 127 |
+
|
| 128 |
+
Returns one of: 'openrouter', 'gemini', 'ollama', 'disabled'.
|
| 129 |
+
"""
|
| 130 |
+
# 1. OpenRouter (primary) – needs API key
|
| 131 |
+
if settings.OPENROUTER_API_KEY:
|
| 132 |
+
return "openrouter"
|
| 133 |
+
|
| 134 |
+
# 2. Gemini – needs API key
|
| 135 |
if settings.USE_GEMINI_FALLBACK and settings.GEMINI_API_KEY:
|
| 136 |
+
logger.info("OpenRouter not configured; falling back to Gemini")
|
| 137 |
return "gemini"
|
| 138 |
+
|
| 139 |
+
# 3. Ollama – local, needs running daemon
|
| 140 |
if self._ollama_available is None:
|
| 141 |
self._ollama_available = await self.check_ollama_health()
|
|
|
|
| 142 |
if self._ollama_available:
|
| 143 |
+
logger.info("Using Ollama as last-resort provider")
|
| 144 |
return "ollama"
|
| 145 |
+
|
|
|
|
|
|
|
| 146 |
return "disabled"
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
# Public API
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
async def stream_generate(
|
| 152 |
self,
|
| 153 |
user_message: str,
|
| 154 |
context: str,
|
| 155 |
+
chat_history: Optional[list] = None,
|
| 156 |
) -> AsyncGenerator[str, None]:
|
| 157 |
+
"""Yield tokens as they are generated."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
provider = await self._ensure_provider()
|
| 159 |
+
messages = self._build_messages(user_message, context, chat_history)
|
| 160 |
+
|
| 161 |
+
if provider == "openrouter":
|
| 162 |
+
async for token in self._stream_openrouter(messages):
|
|
|
|
|
|
|
| 163 |
yield token
|
| 164 |
elif provider == "gemini":
|
| 165 |
+
prompt = self._build_prompt(user_message, context, chat_history)
|
| 166 |
+
async for token in self._stream_gemini(prompt):
|
| 167 |
+
yield token
|
| 168 |
+
elif provider == "ollama":
|
| 169 |
+
prompt = self._build_prompt(user_message, context, chat_history)
|
| 170 |
+
async for token in self._stream_ollama(prompt):
|
| 171 |
yield token
|
| 172 |
else:
|
| 173 |
yield (
|
| 174 |
"LLM is not configured. "
|
| 175 |
+
"Set `OPENROUTER_API_KEY` for the primary provider, "
|
| 176 |
+
"or configure Gemini / Ollama as fallbacks."
|
| 177 |
)
|
| 178 |
+
|
| 179 |
+
async def generate(
|
| 180 |
+
self,
|
| 181 |
+
user_message: str,
|
| 182 |
+
context: str,
|
| 183 |
+
chat_history: Optional[list] = None,
|
| 184 |
+
) -> str:
|
| 185 |
+
"""Generate a complete (non-streaming) response."""
|
| 186 |
+
parts: list[str] = []
|
| 187 |
+
async for token in self.stream_generate(user_message, context, chat_history):
|
| 188 |
+
parts.append(token)
|
| 189 |
+
return "".join(parts)
|
| 190 |
+
|
| 191 |
+
# ------------------------------------------------------------------
|
| 192 |
+
# Message / prompt builders
|
| 193 |
+
# ------------------------------------------------------------------
|
| 194 |
+
def _build_messages(
|
| 195 |
+
self,
|
| 196 |
+
user_message: str,
|
| 197 |
+
context: str,
|
| 198 |
+
chat_history: Optional[list] = None,
|
| 199 |
+
) -> List[Dict[str, str]]:
|
| 200 |
+
"""Build OpenAI-style messages array for OpenRouter."""
|
| 201 |
+
messages: List[Dict[str, str]] = [
|
| 202 |
+
{"role": "system", "content": MEDICAL_SYSTEM_PROMPT},
|
| 203 |
+
]
|
| 204 |
+
|
| 205 |
+
if context:
|
| 206 |
+
messages.append({
|
| 207 |
+
"role": "system",
|
| 208 |
+
"content": f"--- USER'S HEALTH DATA ---\n{context}\n--- END HEALTH DATA ---",
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
if chat_history:
|
| 212 |
+
for msg in chat_history[-10:]:
|
| 213 |
+
role = msg.get("role", "user")
|
| 214 |
+
messages.append({"role": role, "content": msg.get("content", "")})
|
| 215 |
+
|
| 216 |
+
messages.append({"role": "user", "content": user_message})
|
| 217 |
+
return messages
|
| 218 |
+
|
| 219 |
def _build_prompt(
|
| 220 |
self,
|
| 221 |
user_message: str,
|
| 222 |
context: str,
|
| 223 |
+
chat_history: Optional[list] = None,
|
| 224 |
) -> str:
|
| 225 |
+
"""Build a flat prompt string (Gemini / Ollama)."""
|
| 226 |
+
parts = [MEDICAL_SYSTEM_PROMPT]
|
| 227 |
+
|
| 228 |
if context:
|
| 229 |
+
parts.append(f"\n\n--- USER'S HEALTH DATA ---\n{context}\n--- END HEALTH DATA ---")
|
| 230 |
+
|
| 231 |
if chat_history:
|
| 232 |
+
parts.append("\n\n--- CONVERSATION HISTORY ---")
|
| 233 |
+
for msg in chat_history[-10:]:
|
| 234 |
role = "User" if msg.get("role") == "user" else "Assistant"
|
| 235 |
+
parts.append(f"{role}: {msg.get('content', '')}")
|
| 236 |
+
parts.append("--- END HISTORY ---")
|
| 237 |
+
|
| 238 |
+
parts.append(f"\n\nUser: {user_message}\n\nAssistant:")
|
| 239 |
+
return "\n".join(parts)
|
| 240 |
+
|
| 241 |
+
# ------------------------------------------------------------------
|
| 242 |
+
# OpenRouter streaming (primary + in-band fallback model)
|
| 243 |
+
# ------------------------------------------------------------------
|
| 244 |
+
async def _stream_openrouter(
|
| 245 |
+
self,
|
| 246 |
+
messages: List[Dict[str, str]],
|
| 247 |
+
) -> AsyncGenerator[str, None]:
|
| 248 |
+
"""Stream from OpenRouter, falling back through models then providers."""
|
| 249 |
+
models_to_try = [
|
| 250 |
+
settings.OPENROUTER_MODEL, # openrouter/pony-alpha
|
| 251 |
+
settings.OPENROUTER_FALLBACK_MODEL, # upstage/solar-pro-3:free
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
last_error: Optional[Exception] = None
|
| 255 |
+
|
| 256 |
+
for model in models_to_try:
|
| 257 |
+
try:
|
| 258 |
+
client = self._get_openrouter_client()
|
| 259 |
+
logger.info("Streaming from OpenRouter model: %s", model)
|
| 260 |
+
|
| 261 |
+
stream = await client.chat.completions.create(
|
| 262 |
+
model=model,
|
| 263 |
+
messages=messages,
|
| 264 |
+
stream=True,
|
| 265 |
+
temperature=0.7,
|
| 266 |
+
top_p=0.9,
|
| 267 |
+
max_tokens=2048,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
had_content = False
|
| 271 |
+
async for chunk in stream:
|
| 272 |
+
delta = chunk.choices[0].delta if chunk.choices else None
|
| 273 |
+
if delta and delta.content:
|
| 274 |
+
had_content = True
|
| 275 |
+
yield delta.content
|
| 276 |
+
|
| 277 |
+
if had_content:
|
| 278 |
+
return # success – done
|
| 279 |
+
else:
|
| 280 |
+
logger.warning("OpenRouter model %s returned empty stream", model)
|
| 281 |
+
|
| 282 |
+
except Exception as e:
|
| 283 |
+
last_error = e
|
| 284 |
+
logger.warning("OpenRouter model %s failed: %s", model, e)
|
| 285 |
+
continue
|
| 286 |
+
|
| 287 |
+
# All OpenRouter models failed – cascade to Gemini → Ollama
|
| 288 |
+
logger.error("All OpenRouter models exhausted. Cascading to Gemini/Ollama...")
|
| 289 |
+
|
| 290 |
+
# Try Gemini
|
| 291 |
+
if settings.USE_GEMINI_FALLBACK and settings.GEMINI_API_KEY:
|
| 292 |
+
logger.info("Falling back to Gemini after OpenRouter failure")
|
| 293 |
+
prompt = "\n".join(
|
| 294 |
+
m["content"] for m in messages
|
| 295 |
+
)
|
| 296 |
+
async for token in self._stream_gemini(prompt):
|
| 297 |
+
yield token
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
# Try Ollama (last resort)
|
| 301 |
+
if self._ollama_available is None:
|
| 302 |
+
self._ollama_available = await self.check_ollama_health()
|
| 303 |
+
if self._ollama_available:
|
| 304 |
+
logger.info("Falling back to Ollama after OpenRouter failure")
|
| 305 |
+
prompt = "\n".join(
|
| 306 |
+
m["content"] for m in messages
|
| 307 |
+
)
|
| 308 |
+
async for token in self._stream_ollama(prompt):
|
| 309 |
+
yield token
|
| 310 |
+
return
|
| 311 |
+
|
| 312 |
+
yield f"Error: All LLM providers failed. Last error: {last_error}"
|
| 313 |
+
|
| 314 |
+
# ------------------------------------------------------------------
|
| 315 |
+
# Ollama streaming (last resort)
|
| 316 |
+
# ------------------------------------------------------------------
|
| 317 |
async def _stream_ollama(self, prompt: str) -> AsyncGenerator[str, None]:
|
| 318 |
"""Stream response from Ollama."""
|
| 319 |
import ollama
|
| 320 |
+
|
|
|
|
| 321 |
host = _resolved_ollama_base_url or settings.OLLAMA_BASE_URL
|
| 322 |
client = ollama.AsyncClient(host=host)
|
| 323 |
+
|
| 324 |
try:
|
| 325 |
response = await client.chat(
|
| 326 |
model=settings.OLLAMA_MODEL,
|
| 327 |
messages=[{"role": "user", "content": prompt}],
|
| 328 |
stream=True,
|
| 329 |
+
options={"temperature": 0.7, "top_p": 0.9},
|
|
|
|
|
|
|
|
|
|
| 330 |
)
|
| 331 |
+
|
| 332 |
async for chunk in response:
|
| 333 |
if chunk.get("message", {}).get("content"):
|
| 334 |
yield chunk["message"]["content"]
|
| 335 |
+
|
| 336 |
except Exception as e:
|
| 337 |
+
logger.error("Ollama streaming error: %s", e)
|
|
|
|
| 338 |
self._ollama_available = False
|
| 339 |
+
yield f"Error: Ollama failed – {e}"
|
| 340 |
+
|
| 341 |
+
# ------------------------------------------------------------------
|
| 342 |
+
# Gemini streaming
|
| 343 |
+
# ------------------------------------------------------------------
|
|
|
|
|
|
|
| 344 |
async def _stream_gemini(self, prompt: str) -> AsyncGenerator[str, None]:
|
| 345 |
"""Stream response from Gemini API."""
|
| 346 |
try:
|
| 347 |
import google.generativeai as genai
|
| 348 |
+
|
| 349 |
if not self._gemini_client:
|
| 350 |
genai.configure(api_key=settings.GEMINI_API_KEY)
|
| 351 |
self._gemini_client = genai.GenerativeModel("gemini-flash-latest")
|
| 352 |
+
|
| 353 |
response = await asyncio.to_thread(
|
| 354 |
lambda: self._gemini_client.generate_content(
|
| 355 |
prompt,
|
|
|
|
| 358 |
"temperature": 0.7,
|
| 359 |
"top_p": 0.9,
|
| 360 |
"max_output_tokens": 2048,
|
| 361 |
+
},
|
| 362 |
)
|
| 363 |
)
|
| 364 |
+
|
| 365 |
for chunk in response:
|
| 366 |
if chunk.text:
|
| 367 |
yield chunk.text
|
| 368 |
+
|
| 369 |
except Exception as e:
|
| 370 |
+
logger.error("Gemini streaming error: %s", e)
|
| 371 |
+
yield f"Error: Gemini failed ��� {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
|
| 374 |
+
# ---------------------------------------------------------------------------
|
| 375 |
+
# Singleton
|
| 376 |
+
# ---------------------------------------------------------------------------
|
| 377 |
_llm_service: Optional[LLMService] = None
|
| 378 |
|
| 379 |
|
|
@@ -1,7 +1,11 @@
|
|
| 1 |
import logging
|
| 2 |
import asyncio
|
|
|
|
|
|
|
| 3 |
import threading
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
from app.settings import settings
|
| 6 |
|
| 7 |
# Configure logger
|
|
@@ -23,10 +27,78 @@ def _import_mem0_memory():
|
|
| 23 |
return None
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
class MemoryService:
|
| 27 |
"""
|
| 28 |
Service for interacting with Mem0 memory layer.
|
| 29 |
Stores unstructured user preferences, facts, and conversation history.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
"""
|
| 31 |
|
| 32 |
_instance = None
|
|
@@ -41,42 +113,176 @@ class MemoryService:
|
|
| 41 |
if self._initialized:
|
| 42 |
return
|
| 43 |
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"vector_store": {
|
| 46 |
"provider": "chroma",
|
| 47 |
"config": {
|
| 48 |
"collection_name": settings.MEM0_COLLECTION,
|
| 49 |
-
"path": settings.
|
| 50 |
-
}
|
| 51 |
-
},
|
| 52 |
-
"llm": {
|
| 53 |
-
"provider": "ollama",
|
| 54 |
-
"config": {
|
| 55 |
-
"model": settings.OLLAMA_MODEL,
|
| 56 |
-
"base_url": settings.OLLAMA_BASE_URL,
|
| 57 |
-
"timeout": settings.OLLAMA_TIMEOUT,
|
| 58 |
-
}
|
| 59 |
},
|
| 60 |
"graph_store": {
|
| 61 |
"provider": "neo4j",
|
| 62 |
"config": {
|
| 63 |
"url": settings.NEO4J_URI,
|
| 64 |
"username": settings.NEO4J_USER,
|
| 65 |
-
"password": settings.NEO4J_PASSWORD
|
| 66 |
-
}
|
| 67 |
-
}
|
| 68 |
}
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
self._client_lock = threading.Lock()
|
| 74 |
-
self._initialized = True
|
| 75 |
-
logger.info("MemoryService initialized (lazy loading client)")
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def _get_client(self):
|
| 82 |
"""Get or initialize the Mem0 client"""
|
|
@@ -88,38 +294,166 @@ class MemoryService:
|
|
| 88 |
if self.memory_client is None:
|
| 89 |
with self._client_lock:
|
| 90 |
if self.memory_client is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
try:
|
| 92 |
logger.info("Initializing Mem0 client connection...")
|
| 93 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
logger.info("Mem0 client initialized successfully")
|
| 95 |
except Exception as e:
|
| 96 |
logger.error(f"Failed to initialize Mem0 client: {e}")
|
|
|
|
| 97 |
raise
|
| 98 |
return self.memory_client
|
| 99 |
|
| 100 |
-
async def add(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
"""
|
| 102 |
Add a memory for a user asynchronously.
|
| 103 |
|
|
|
|
|
|
|
|
|
|
| 104 |
Args:
|
| 105 |
content: The text content to remember
|
| 106 |
user_id: The user ID (will be used as user_id in Mem0)
|
| 107 |
metadata: Optional metadata to attach
|
|
|
|
| 108 |
"""
|
|
|
|
|
|
|
|
|
|
| 109 |
def _sync_add():
|
| 110 |
client = self._get_client()
|
| 111 |
return client.add(content, user_id=user_id, metadata=metadata)
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
async def search(self, query: str, user_id: str, limit: int = 5) -> List[Dict[str, Any]]:
|
| 120 |
"""
|
| 121 |
Search memories for a user asynchronously.
|
| 122 |
|
|
|
|
|
|
|
|
|
|
| 123 |
Args:
|
| 124 |
query: Search query
|
| 125 |
user_id: The user ID to scope search to
|
|
@@ -129,10 +463,33 @@ class MemoryService:
|
|
| 129 |
client = self._get_client()
|
| 130 |
return client.search(query, user_id=user_id, limit=limit)
|
| 131 |
|
|
|
|
| 132 |
try:
|
| 133 |
-
|
|
|
|
|
|
|
| 134 |
except Exception as e:
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
return []
|
| 137 |
|
| 138 |
async def get_all(self, user_id: str) -> List[Dict[str, Any]]:
|
|
@@ -144,9 +501,12 @@ class MemoryService:
|
|
| 144 |
return client.get_all(user_id=user_id)
|
| 145 |
|
| 146 |
try:
|
| 147 |
-
|
|
|
|
|
|
|
| 148 |
except Exception as e:
|
| 149 |
logger.error(f"Error getting all memories for user {user_id}: {e}")
|
|
|
|
| 150 |
return []
|
| 151 |
|
| 152 |
async def delete(self, memory_id: str) -> bool:
|
|
@@ -159,9 +519,12 @@ class MemoryService:
|
|
| 159 |
return True
|
| 160 |
|
| 161 |
try:
|
| 162 |
-
|
|
|
|
|
|
|
| 163 |
except Exception as e:
|
| 164 |
logger.error(f"Error deleting memory {memory_id}: {e}")
|
|
|
|
| 165 |
return False
|
| 166 |
|
| 167 |
async def delete_all(self, user_id: str) -> bool:
|
|
@@ -174,9 +537,12 @@ class MemoryService:
|
|
| 174 |
return True
|
| 175 |
|
| 176 |
try:
|
| 177 |
-
|
|
|
|
|
|
|
| 178 |
except Exception as e:
|
| 179 |
logger.error(f"Error deleting all memories for user {user_id}: {e}")
|
|
|
|
| 180 |
return False
|
| 181 |
|
| 182 |
# Global instance
|
|
|
|
| 1 |
import logging
|
| 2 |
import asyncio
|
| 3 |
+
import random
|
| 4 |
+
import re
|
| 5 |
import threading
|
| 6 |
+
import copy
|
| 7 |
+
import time
|
| 8 |
+
from typing import List, Dict, Any, Optional, Tuple
|
| 9 |
from app.settings import settings
|
| 10 |
|
| 11 |
# Configure logger
|
|
|
|
| 27 |
return None
|
| 28 |
|
| 29 |
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Groq / LLM call throttle
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def _extract_retry_seconds(error_text: str) -> float:
|
| 35 |
+
"""Parse 'try again in <N>s' from Groq 429 responses."""
|
| 36 |
+
match = re.search(r"try again in ([0-9.]+)s", error_text, re.IGNORECASE)
|
| 37 |
+
if match:
|
| 38 |
+
try:
|
| 39 |
+
return float(match.group(1))
|
| 40 |
+
except (TypeError, ValueError):
|
| 41 |
+
pass
|
| 42 |
+
return 0.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _is_rate_limit_error(error_text: str) -> bool:
|
| 46 |
+
"""Check if an error string indicates a Groq/OpenAI 429 rate limit."""
|
| 47 |
+
lower = error_text.lower()
|
| 48 |
+
return "rate_limit" in lower or "429" in lower or "rate limit" in lower
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class _GroqThrottle:
|
| 52 |
+
"""
|
| 53 |
+
Async token-bucket style throttle for outbound Mem0→Groq calls.
|
| 54 |
+
|
| 55 |
+
Enforces a minimum interval between consecutive calls so the
|
| 56 |
+
aggregate tokens-per-minute stays under Groq's free-tier quota.
|
| 57 |
+
All callers in the process share a single lock.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(self, min_interval: float):
|
| 61 |
+
self._min_interval = max(min_interval, 0.0)
|
| 62 |
+
self._lock: Optional[asyncio.Lock] = None
|
| 63 |
+
self._last_call: float = 0.0
|
| 64 |
+
|
| 65 |
+
def _ensure_lock(self) -> asyncio.Lock:
|
| 66 |
+
"""Lazily create the lock inside the running event loop."""
|
| 67 |
+
if self._lock is None:
|
| 68 |
+
self._lock = asyncio.Lock()
|
| 69 |
+
return self._lock
|
| 70 |
+
|
| 71 |
+
async def acquire(self) -> None:
|
| 72 |
+
"""Wait until enough time has elapsed since the last call."""
|
| 73 |
+
lock = self._ensure_lock()
|
| 74 |
+
async with lock:
|
| 75 |
+
now = time.monotonic()
|
| 76 |
+
elapsed = now - self._last_call
|
| 77 |
+
if elapsed < self._min_interval:
|
| 78 |
+
wait = self._min_interval - elapsed
|
| 79 |
+
logger.debug("GroqThrottle: waiting %.2fs before next Mem0 call", wait)
|
| 80 |
+
await asyncio.sleep(wait)
|
| 81 |
+
self._last_call = time.monotonic()
|
| 82 |
+
|
| 83 |
+
async def backoff(self, seconds: float) -> None:
|
| 84 |
+
"""Push the next-allowed timestamp forward (e.g. after a 429)."""
|
| 85 |
+
lock = self._ensure_lock()
|
| 86 |
+
async with lock:
|
| 87 |
+
self._last_call = time.monotonic() + seconds - self._min_interval
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Module-level throttle shared by every MemoryService instance.
|
| 91 |
+
_groq_throttle = _GroqThrottle(settings.MEM0_CALL_INTERVAL_SECONDS)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
class MemoryService:
|
| 95 |
"""
|
| 96 |
Service for interacting with Mem0 memory layer.
|
| 97 |
Stores unstructured user preferences, facts, and conversation history.
|
| 98 |
+
|
| 99 |
+
All outbound calls are throttled through ``_groq_throttle`` so that
|
| 100 |
+
aggregate Groq TPM usage stays within quota. Individual ``add()``
|
| 101 |
+
calls self-retry with exponential back-off on 429 errors.
|
| 102 |
"""
|
| 103 |
|
| 104 |
_instance = None
|
|
|
|
| 113 |
if self._initialized:
|
| 114 |
return
|
| 115 |
|
| 116 |
+
# Lazy initialization of the Memory client
|
| 117 |
+
self._memory_cls = _import_mem0_memory()
|
| 118 |
+
self.memory_client = None
|
| 119 |
+
self._client_lock = threading.Lock()
|
| 120 |
+
self._last_init_error: Optional[str] = None
|
| 121 |
+
self._last_init_attempt_at: float = 0.0
|
| 122 |
+
self._init_retry_cooldown_seconds = 10
|
| 123 |
+
self._active_config_name: Optional[str] = None
|
| 124 |
+
|
| 125 |
+
self.last_error: Optional[str] = None
|
| 126 |
+
self.config_candidates = self._build_config_candidates()
|
| 127 |
+
# For compatibility/debugging: populated with the selected config after init.
|
| 128 |
+
self.config: Optional[Dict[str, Any]] = None
|
| 129 |
+
self._initialized = True
|
| 130 |
+
logger.info("MemoryService initialized (lazy loading client)")
|
| 131 |
+
|
| 132 |
+
@property
|
| 133 |
+
def is_available(self) -> bool:
|
| 134 |
+
return self._memory_cls is not None
|
| 135 |
+
|
| 136 |
+
def _base_store_config(self) -> Dict[str, Any]:
|
| 137 |
+
return {
|
| 138 |
"vector_store": {
|
| 139 |
"provider": "chroma",
|
| 140 |
"config": {
|
| 141 |
"collection_name": settings.MEM0_COLLECTION,
|
| 142 |
+
"path": settings.MEM0_CHROMA_DIR, # Separate from RAG's ChromaDB
|
| 143 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
},
|
| 145 |
"graph_store": {
|
| 146 |
"provider": "neo4j",
|
| 147 |
"config": {
|
| 148 |
"url": settings.NEO4J_URI,
|
| 149 |
"username": settings.NEO4J_USER,
|
| 150 |
+
"password": settings.NEO4J_PASSWORD,
|
| 151 |
+
},
|
| 152 |
+
},
|
| 153 |
}
|
| 154 |
|
| 155 |
+
def _build_llm_candidates(self) -> List[Tuple[str, Dict[str, Any]]]:
|
| 156 |
+
groq_candidates: List[Tuple[str, Dict[str, Any]]] = []
|
| 157 |
+
ollama_candidates: List[Tuple[str, Dict[str, Any]]] = []
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
# Groq support via OpenAI-compatible config (preferred).
|
| 160 |
+
if settings.groq_api_key:
|
| 161 |
+
groq_candidates.append(
|
| 162 |
+
(
|
| 163 |
+
"groq_openai_compat",
|
| 164 |
+
{
|
| 165 |
+
"provider": "openai",
|
| 166 |
+
"config": {
|
| 167 |
+
"api_key": settings.groq_api_key,
|
| 168 |
+
"model": settings.MEM0_GROQ_MODEL,
|
| 169 |
+
"openai_base_url": settings.groq_api_base,
|
| 170 |
+
"temperature": 0.1,
|
| 171 |
+
},
|
| 172 |
+
},
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Only add Ollama candidates if Groq is not preferred or not available.
|
| 177 |
+
# This prevents noisy "Failed to connect to Ollama" warnings.
|
| 178 |
+
if not (settings.MEM0_PREFER_GROQ and settings.groq_api_key):
|
| 179 |
+
ollama_model = settings.OLLAMA_MODEL
|
| 180 |
+
ollama_url = settings.OLLAMA_BASE_URL
|
| 181 |
+
ollama_candidates.extend(
|
| 182 |
+
[
|
| 183 |
+
(
|
| 184 |
+
"ollama_ollama_base_url",
|
| 185 |
+
{
|
| 186 |
+
"provider": "ollama",
|
| 187 |
+
"config": {
|
| 188 |
+
"model": ollama_model,
|
| 189 |
+
"ollama_base_url": ollama_url,
|
| 190 |
+
},
|
| 191 |
+
},
|
| 192 |
+
),
|
| 193 |
+
(
|
| 194 |
+
"ollama_model_only",
|
| 195 |
+
{
|
| 196 |
+
"provider": "ollama",
|
| 197 |
+
"config": {
|
| 198 |
+
"model": ollama_model,
|
| 199 |
+
},
|
| 200 |
+
},
|
| 201 |
+
),
|
| 202 |
+
]
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
return groq_candidates + ollama_candidates
|
| 206 |
+
|
| 207 |
+
def _build_embedder_candidates(self) -> List[Tuple[str, Dict[str, Any]]]:
|
| 208 |
+
# Prefer local sentence-transformers embedder (works without Ollama or external APIs).
|
| 209 |
+
# Only include Ollama embedder candidates if we're not preferring Groq (i.e., Ollama is expected to run).
|
| 210 |
+
candidates = [
|
| 211 |
+
(
|
| 212 |
+
"huggingface_local",
|
| 213 |
+
{
|
| 214 |
+
"provider": "huggingface",
|
| 215 |
+
"config": {
|
| 216 |
+
"model": settings.EMBEDDING_MODEL,
|
| 217 |
+
},
|
| 218 |
+
},
|
| 219 |
+
),
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
# Skip Ollama embedder candidates when using Groq for LLM (Ollama likely not running).
|
| 223 |
+
if not (settings.MEM0_PREFER_GROQ and settings.groq_api_key):
|
| 224 |
+
embed_model = settings.MEM0_EMBED_MODEL
|
| 225 |
+
ollama_url = settings.OLLAMA_BASE_URL
|
| 226 |
+
candidates.extend([
|
| 227 |
+
(
|
| 228 |
+
"ollama_embed_ollama_base_url",
|
| 229 |
+
{
|
| 230 |
+
"provider": "ollama",
|
| 231 |
+
"config": {
|
| 232 |
+
"model": embed_model,
|
| 233 |
+
"ollama_base_url": ollama_url,
|
| 234 |
+
},
|
| 235 |
+
},
|
| 236 |
+
),
|
| 237 |
+
(
|
| 238 |
+
"ollama_embed_model_only",
|
| 239 |
+
{
|
| 240 |
+
"provider": "ollama",
|
| 241 |
+
"config": {
|
| 242 |
+
"model": embed_model,
|
| 243 |
+
},
|
| 244 |
+
},
|
| 245 |
+
),
|
| 246 |
+
])
|
| 247 |
+
|
| 248 |
+
return candidates
|
| 249 |
+
|
| 250 |
+
def _build_config_candidates(self) -> List[Tuple[str, Dict[str, Any]]]:
|
| 251 |
+
base = self._base_store_config()
|
| 252 |
+
llm_candidates = self._build_llm_candidates()
|
| 253 |
+
embedder_candidates = self._build_embedder_candidates()
|
| 254 |
+
|
| 255 |
+
configs: List[Tuple[str, Dict[str, Any]]] = []
|
| 256 |
+
seen_signatures = set()
|
| 257 |
+
|
| 258 |
+
for llm_name, llm_config in llm_candidates:
|
| 259 |
+
# With explicit embedder config
|
| 260 |
+
for embed_name, embedder_config in embedder_candidates:
|
| 261 |
+
cfg = copy.deepcopy(base)
|
| 262 |
+
cfg["llm"] = copy.deepcopy(llm_config)
|
| 263 |
+
cfg["embedder"] = copy.deepcopy(embedder_config)
|
| 264 |
+
|
| 265 |
+
signature = repr(cfg)
|
| 266 |
+
if signature in seen_signatures:
|
| 267 |
+
continue
|
| 268 |
+
seen_signatures.add(signature)
|
| 269 |
+
configs.append((f"{llm_name}+{embed_name}", cfg))
|
| 270 |
+
|
| 271 |
+
# Fallback: let Mem0 decide embedder defaults
|
| 272 |
+
cfg = copy.deepcopy(base)
|
| 273 |
+
cfg["llm"] = copy.deepcopy(llm_config)
|
| 274 |
+
signature = repr(cfg)
|
| 275 |
+
if signature not in seen_signatures:
|
| 276 |
+
seen_signatures.add(signature)
|
| 277 |
+
configs.append((f"{llm_name}+default_embedder", cfg))
|
| 278 |
+
|
| 279 |
+
return configs
|
| 280 |
+
|
| 281 |
+
def _set_error(self, message: str) -> None:
|
| 282 |
+
self.last_error = message
|
| 283 |
+
|
| 284 |
+
def _clear_error(self) -> None:
|
| 285 |
+
self.last_error = None
|
| 286 |
|
| 287 |
def _get_client(self):
|
| 288 |
"""Get or initialize the Mem0 client"""
|
|
|
|
| 294 |
if self.memory_client is None:
|
| 295 |
with self._client_lock:
|
| 296 |
if self.memory_client is None:
|
| 297 |
+
now = time.time()
|
| 298 |
+
if (
|
| 299 |
+
self._last_init_error
|
| 300 |
+
and (now - self._last_init_attempt_at) < self._init_retry_cooldown_seconds
|
| 301 |
+
):
|
| 302 |
+
raise RuntimeError(self._last_init_error)
|
| 303 |
try:
|
| 304 |
logger.info("Initializing Mem0 client connection...")
|
| 305 |
+
self._last_init_attempt_at = now
|
| 306 |
+
|
| 307 |
+
init_errors = []
|
| 308 |
+
for config_name, candidate in self.config_candidates:
|
| 309 |
+
try:
|
| 310 |
+
self.memory_client = self._memory_cls.from_config(candidate)
|
| 311 |
+
self._active_config_name = config_name
|
| 312 |
+
self.config = candidate
|
| 313 |
+
self._last_init_error = None
|
| 314 |
+
self._clear_error()
|
| 315 |
+
logger.info(
|
| 316 |
+
"Mem0 client initialized successfully (config=%s)",
|
| 317 |
+
config_name,
|
| 318 |
+
)
|
| 319 |
+
break
|
| 320 |
+
except Exception as candidate_error:
|
| 321 |
+
err = f"{config_name}: {candidate_error}"
|
| 322 |
+
init_errors.append(err)
|
| 323 |
+
logger.warning("Mem0 config attempt failed (%s)", err)
|
| 324 |
+
|
| 325 |
+
if self.memory_client is None:
|
| 326 |
+
joined = "; ".join(init_errors) if init_errors else "Unknown Mem0 init error"
|
| 327 |
+
self._last_init_error = f"Mem0 initialization failed: {joined}"
|
| 328 |
+
raise RuntimeError(self._last_init_error)
|
| 329 |
logger.info("Mem0 client initialized successfully")
|
| 330 |
except Exception as e:
|
| 331 |
logger.error(f"Failed to initialize Mem0 client: {e}")
|
| 332 |
+
self._set_error(str(e))
|
| 333 |
raise
|
| 334 |
return self.memory_client
|
| 335 |
|
| 336 |
+
async def add(
|
| 337 |
+
self,
|
| 338 |
+
content: str,
|
| 339 |
+
user_id: str,
|
| 340 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 341 |
+
*,
|
| 342 |
+
_max_retries: Optional[int] = None,
|
| 343 |
+
) -> Dict[str, Any]:
|
| 344 |
"""
|
| 345 |
Add a memory for a user asynchronously.
|
| 346 |
|
| 347 |
+
Automatically throttles outbound Groq calls and retries on 429
|
| 348 |
+
rate-limit errors with exponential back-off + jitter.
|
| 349 |
+
|
| 350 |
Args:
|
| 351 |
content: The text content to remember
|
| 352 |
user_id: The user ID (will be used as user_id in Mem0)
|
| 353 |
metadata: Optional metadata to attach
|
| 354 |
+
_max_retries: Override default retry count (settings.MEM0_MAX_RETRIES)
|
| 355 |
"""
|
| 356 |
+
max_retries = _max_retries if _max_retries is not None else settings.MEM0_MAX_RETRIES
|
| 357 |
+
base_backoff = settings.MEM0_RETRY_BASE_SECONDS
|
| 358 |
+
|
| 359 |
def _sync_add():
|
| 360 |
client = self._get_client()
|
| 361 |
return client.add(content, user_id=user_id, metadata=metadata)
|
| 362 |
|
| 363 |
+
last_err: Optional[str] = None
|
| 364 |
+
|
| 365 |
+
for attempt in range(1, max_retries + 1):
|
| 366 |
+
# ---- throttle: wait for our turn ----
|
| 367 |
+
await _groq_throttle.acquire()
|
| 368 |
+
|
| 369 |
+
try:
|
| 370 |
+
result = await asyncio.to_thread(_sync_add)
|
| 371 |
+
except Exception as e:
|
| 372 |
+
err_text = str(e)
|
| 373 |
+
if _is_rate_limit_error(err_text):
|
| 374 |
+
retry_after = _extract_retry_seconds(err_text)
|
| 375 |
+
wait = max(retry_after, base_backoff * attempt) + random.uniform(0.5, 1.5)
|
| 376 |
+
logger.warning(
|
| 377 |
+
"Groq 429 during Mem0 add for user %s (attempt %s/%s). "
|
| 378 |
+
"Backing off %.1fs",
|
| 379 |
+
user_id, attempt, max_retries, wait,
|
| 380 |
+
)
|
| 381 |
+
await _groq_throttle.backoff(wait)
|
| 382 |
+
await asyncio.sleep(wait)
|
| 383 |
+
last_err = err_text
|
| 384 |
+
continue
|
| 385 |
+
# Non-rate-limit error → don't retry
|
| 386 |
+
logger.error("Error adding memory for user %s: %s", user_id, e)
|
| 387 |
+
self._set_error(err_text)
|
| 388 |
+
return {"error": err_text}
|
| 389 |
+
|
| 390 |
+
# Mem0 may return success dict but with error payload
|
| 391 |
+
if isinstance(result, dict) and result.get("error"):
|
| 392 |
+
err_text = str(result["error"])
|
| 393 |
+
if _is_rate_limit_error(err_text) and attempt < max_retries:
|
| 394 |
+
retry_after = _extract_retry_seconds(err_text)
|
| 395 |
+
wait = max(retry_after, base_backoff * attempt) + random.uniform(0.5, 1.5)
|
| 396 |
+
logger.warning(
|
| 397 |
+
"Groq 429 in Mem0 result for user %s (attempt %s/%s). "
|
| 398 |
+
"Backing off %.1fs",
|
| 399 |
+
user_id, attempt, max_retries, wait,
|
| 400 |
+
)
|
| 401 |
+
await _groq_throttle.backoff(wait)
|
| 402 |
+
await asyncio.sleep(wait)
|
| 403 |
+
last_err = err_text
|
| 404 |
+
continue
|
| 405 |
+
# Non-retryable error in result
|
| 406 |
+
self._set_error(err_text)
|
| 407 |
+
return result
|
| 408 |
+
|
| 409 |
+
# ---- success ----
|
| 410 |
+
self._clear_error()
|
| 411 |
+
return result
|
| 412 |
+
|
| 413 |
+
# Exhausted all retries
|
| 414 |
+
logger.error(
|
| 415 |
+
"Mem0 add exhausted %s retries for user %s. Last error: %s",
|
| 416 |
+
max_retries, user_id, last_err,
|
| 417 |
+
)
|
| 418 |
+
self._set_error(last_err or "Rate limit retries exhausted")
|
| 419 |
+
return {"error": last_err or "Rate limit retries exhausted"}
|
| 420 |
+
|
| 421 |
+
async def add_batch(
|
| 422 |
+
self,
|
| 423 |
+
items: List[Dict[str, Any]],
|
| 424 |
+
user_id: str,
|
| 425 |
+
*,
|
| 426 |
+
inter_call_delay: Optional[float] = None,
|
| 427 |
+
) -> List[Dict[str, Any]]:
|
| 428 |
+
"""
|
| 429 |
+
Add multiple memories sequentially with throttling between each.
|
| 430 |
+
|
| 431 |
+
Each item dict must have at minimum ``content`` (str).
|
| 432 |
+
Optional keys: ``metadata`` (dict).
|
| 433 |
+
|
| 434 |
+
Returns a list of results (one per item).
|
| 435 |
+
"""
|
| 436 |
+
delay = inter_call_delay if inter_call_delay is not None else settings.MEM0_BATCH_DELAY_SECONDS
|
| 437 |
+
results: List[Dict[str, Any]] = []
|
| 438 |
+
for idx, item in enumerate(items):
|
| 439 |
+
result = await self.add(
|
| 440 |
+
content=item["content"],
|
| 441 |
+
user_id=user_id,
|
| 442 |
+
metadata=item.get("metadata"),
|
| 443 |
+
)
|
| 444 |
+
results.append(result)
|
| 445 |
+
# Proactive delay between items (throttle.acquire also enforces min interval)
|
| 446 |
+
if idx < len(items) - 1 and delay > 0:
|
| 447 |
+
await asyncio.sleep(delay)
|
| 448 |
+
return results
|
| 449 |
|
| 450 |
async def search(self, query: str, user_id: str, limit: int = 5) -> List[Dict[str, Any]]:
|
| 451 |
"""
|
| 452 |
Search memories for a user asynchronously.
|
| 453 |
|
| 454 |
+
Throttled to respect Groq TPM limits (search also triggers LLM calls
|
| 455 |
+
inside Mem0 for query expansion).
|
| 456 |
+
|
| 457 |
Args:
|
| 458 |
query: Search query
|
| 459 |
user_id: The user ID to scope search to
|
|
|
|
| 463 |
client = self._get_client()
|
| 464 |
return client.search(query, user_id=user_id, limit=limit)
|
| 465 |
|
| 466 |
+
await _groq_throttle.acquire()
|
| 467 |
try:
|
| 468 |
+
result = await asyncio.to_thread(_sync_search)
|
| 469 |
+
self._clear_error()
|
| 470 |
+
return result
|
| 471 |
except Exception as e:
|
| 472 |
+
err_text = str(e)
|
| 473 |
+
if _is_rate_limit_error(err_text):
|
| 474 |
+
retry_after = _extract_retry_seconds(err_text)
|
| 475 |
+
wait = max(retry_after, settings.MEM0_RETRY_BASE_SECONDS) + random.uniform(0.5, 1.5)
|
| 476 |
+
logger.warning(
|
| 477 |
+
"Groq 429 during Mem0 search for user %s — backing off %.1fs and retrying once",
|
| 478 |
+
user_id, wait,
|
| 479 |
+
)
|
| 480 |
+
await _groq_throttle.backoff(wait)
|
| 481 |
+
await asyncio.sleep(wait)
|
| 482 |
+
await _groq_throttle.acquire()
|
| 483 |
+
try:
|
| 484 |
+
result = await asyncio.to_thread(_sync_search)
|
| 485 |
+
self._clear_error()
|
| 486 |
+
return result
|
| 487 |
+
except Exception as e2:
|
| 488 |
+
logger.error("Mem0 search retry also failed for user %s: %s", user_id, e2)
|
| 489 |
+
self._set_error(str(e2))
|
| 490 |
+
return []
|
| 491 |
+
logger.error("Error searching memories for user %s: %s", user_id, e)
|
| 492 |
+
self._set_error(err_text)
|
| 493 |
return []
|
| 494 |
|
| 495 |
async def get_all(self, user_id: str) -> List[Dict[str, Any]]:
|
|
|
|
| 501 |
return client.get_all(user_id=user_id)
|
| 502 |
|
| 503 |
try:
|
| 504 |
+
result = await asyncio.to_thread(_sync_get_all)
|
| 505 |
+
self._clear_error()
|
| 506 |
+
return result
|
| 507 |
except Exception as e:
|
| 508 |
logger.error(f"Error getting all memories for user {user_id}: {e}")
|
| 509 |
+
self._set_error(str(e))
|
| 510 |
return []
|
| 511 |
|
| 512 |
async def delete(self, memory_id: str) -> bool:
|
|
|
|
| 519 |
return True
|
| 520 |
|
| 521 |
try:
|
| 522 |
+
result = await asyncio.to_thread(_sync_delete)
|
| 523 |
+
self._clear_error()
|
| 524 |
+
return result
|
| 525 |
except Exception as e:
|
| 526 |
logger.error(f"Error deleting memory {memory_id}: {e}")
|
| 527 |
+
self._set_error(str(e))
|
| 528 |
return False
|
| 529 |
|
| 530 |
async def delete_all(self, user_id: str) -> bool:
|
|
|
|
| 537 |
return True
|
| 538 |
|
| 539 |
try:
|
| 540 |
+
result = await asyncio.to_thread(_sync_delete_all)
|
| 541 |
+
self._clear_error()
|
| 542 |
+
return result
|
| 543 |
except Exception as e:
|
| 544 |
logger.error(f"Error deleting all memories for user {user_id}: {e}")
|
| 545 |
+
self._set_error(str(e))
|
| 546 |
return False
|
| 547 |
|
| 548 |
# Global instance
|
|
@@ -314,6 +314,80 @@ class RAGService:
|
|
| 314 |
context_parts.append(doc["content"])
|
| 315 |
|
| 316 |
return "\n\n---\n\n".join(context_parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
async def add_test_document(self, user_id: uuid.UUID) -> str:
|
| 319 |
"""
|
|
|
|
| 314 |
context_parts.append(doc["content"])
|
| 315 |
|
| 316 |
return "\n\n---\n\n".join(context_parts)
|
| 317 |
+
|
| 318 |
+
async def get_user_context_structured(
|
| 319 |
+
self,
|
| 320 |
+
user_id: uuid.UUID,
|
| 321 |
+
query: str,
|
| 322 |
+
db: Optional[AsyncSession] = None,
|
| 323 |
+
) -> Dict[str, Any]:
|
| 324 |
+
"""
|
| 325 |
+
Get structured context for the LLM along with source metadata for citations.
|
| 326 |
+
|
| 327 |
+
Returns:
|
| 328 |
+
{
|
| 329 |
+
"text": "<formatted context string for the LLM>",
|
| 330 |
+
"sources": [
|
| 331 |
+
{"type": "report", "filename": "...", "report_id": "...", "excerpt": "..."},
|
| 332 |
+
{"type": "observations", "metric_name": "...", "excerpt": "..."},
|
| 333 |
+
...
|
| 334 |
+
]
|
| 335 |
+
}
|
| 336 |
+
"""
|
| 337 |
+
# Sync user data if db provided
|
| 338 |
+
if db:
|
| 339 |
+
try:
|
| 340 |
+
await self.sync_user_reports(user_id, db)
|
| 341 |
+
await self.sync_user_observations(user_id, db)
|
| 342 |
+
except Exception as e:
|
| 343 |
+
logger.warning(f"RAG sync failed for user {user_id}: {e}")
|
| 344 |
+
|
| 345 |
+
try:
|
| 346 |
+
docs = await self.query(user_id, query)
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.warning(f"RAG context unavailable for user {user_id}: {e}")
|
| 349 |
+
return {"text": "No health data available for this user yet.", "sources": []}
|
| 350 |
+
|
| 351 |
+
if not docs:
|
| 352 |
+
return {"text": "No health data available for this user yet.", "sources": []}
|
| 353 |
+
|
| 354 |
+
context_parts: list[str] = []
|
| 355 |
+
sources: list[dict] = []
|
| 356 |
+
|
| 357 |
+
for doc in docs:
|
| 358 |
+
meta = doc.get("metadata", {})
|
| 359 |
+
source_type = meta.get("source_type", "unknown")
|
| 360 |
+
content = doc.get("content", "")
|
| 361 |
+
|
| 362 |
+
if source_type == "report":
|
| 363 |
+
filename = meta.get("filename", "Unknown report")
|
| 364 |
+
report_id = meta.get("report_id")
|
| 365 |
+
context_parts.append(f"[Source: Report '{filename}']\n{content}")
|
| 366 |
+
sources.append({
|
| 367 |
+
"type": "report",
|
| 368 |
+
"filename": filename,
|
| 369 |
+
"report_id": report_id,
|
| 370 |
+
"excerpt": content[:200] + ("..." if len(content) > 200 else ""),
|
| 371 |
+
})
|
| 372 |
+
elif source_type == "observations":
|
| 373 |
+
metric = meta.get("metric_name", "health metric")
|
| 374 |
+
context_parts.append(f"[Source: Lab observations – {metric}]\n{content}")
|
| 375 |
+
sources.append({
|
| 376 |
+
"type": "observations",
|
| 377 |
+
"metric_name": metric,
|
| 378 |
+
"excerpt": content[:200] + ("..." if len(content) > 200 else ""),
|
| 379 |
+
})
|
| 380 |
+
else:
|
| 381 |
+
context_parts.append(content)
|
| 382 |
+
sources.append({
|
| 383 |
+
"type": source_type,
|
| 384 |
+
"excerpt": content[:200] + ("..." if len(content) > 200 else ""),
|
| 385 |
+
})
|
| 386 |
+
|
| 387 |
+
return {
|
| 388 |
+
"text": "\n\n---\n\n".join(context_parts),
|
| 389 |
+
"sources": sources,
|
| 390 |
+
}
|
| 391 |
|
| 392 |
async def add_test_document(self, user_id: uuid.UUID) -> str:
|
| 393 |
"""
|
|
@@ -22,7 +22,16 @@ class Settings(BaseSettings):
|
|
| 22 |
# so existing databases get schema changes (new columns/indexes).
|
| 23 |
AUTO_MIGRATE: bool = False # Disabled - using create_all instead
|
| 24 |
|
| 25 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# Default to localhost for direct (non‑Docker) runs.
|
| 27 |
# When running via Docker, docker-compose.yml overrides this to use
|
| 28 |
# http://host.docker.internal:11434 so the container can reach the host.
|
|
@@ -44,6 +53,9 @@ class Settings(BaseSettings):
|
|
| 44 |
groq_api_key: Optional[str] = None
|
| 45 |
groq_api_base: str = "https://api.groq.com/openai/v1"
|
| 46 |
groq_model: str = "llama-3.3-70b-versatile" # Updated to current model
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
# Grok/xAI API (alternative)
|
| 49 |
grok_api_key: Optional[str] = None
|
|
@@ -70,6 +82,21 @@ class Settings(BaseSettings):
|
|
| 70 |
|
| 71 |
# Memory service (Mem0)
|
| 72 |
MEM0_COLLECTION: str = "user_memories"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
# Graph service (Graphiti)
|
| 75 |
# Note: Neo4j Community Edition only supports "neo4j" database name
|
|
@@ -109,4 +136,3 @@ logger.info(f" SMS_TEST_TO_NUMBER: {settings.SMS_TEST_TO_NUMBER}")
|
|
| 109 |
logger.info(f" TWILIO_ACCOUNT_SID configured: {bool(settings.TWILIO_ACCOUNT_SID)}")
|
| 110 |
logger.info(f" TWILIO_AUTH_TOKEN configured: {bool(settings.TWILIO_AUTH_TOKEN)}")
|
| 111 |
logger.info(f" TWILIO_FROM_NUMBER: {settings.TWILIO_FROM_NUMBER}")
|
| 112 |
-
|
|
|
|
| 22 |
# so existing databases get schema changes (new columns/indexes).
|
| 23 |
AUTO_MIGRATE: bool = False # Disabled - using create_all instead
|
| 24 |
|
| 25 |
+
# =====================
|
| 26 |
+
# OpenRouter Configuration (PRIMARY LLM)
|
| 27 |
+
# =====================
|
| 28 |
+
OPENROUTER_API_KEY: Optional[str] = None
|
| 29 |
+
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
|
| 30 |
+
OPENROUTER_MODEL: str = "openrouter/pony-alpha"
|
| 31 |
+
OPENROUTER_FALLBACK_MODEL: str = "upstage/solar-pro-3:free"
|
| 32 |
+
OPENROUTER_TIMEOUT: int = 120 # seconds
|
| 33 |
+
|
| 34 |
+
# Ollama LLM Configuration (LAST RESORT fallback)
|
| 35 |
# Default to localhost for direct (non‑Docker) runs.
|
| 36 |
# When running via Docker, docker-compose.yml overrides this to use
|
| 37 |
# http://host.docker.internal:11434 so the container can reach the host.
|
|
|
|
| 53 |
groq_api_key: Optional[str] = None
|
| 54 |
groq_api_base: str = "https://api.groq.com/openai/v1"
|
| 55 |
groq_model: str = "llama-3.3-70b-versatile" # Updated to current model
|
| 56 |
+
MEM0_GROQ_MODEL: str = "llama-3.1-8b-instant"
|
| 57 |
+
# Graphiti requires JSON-schema structured outputs; use a Groq model that supports it.
|
| 58 |
+
GRAPHITI_GROQ_MODEL: str = "moonshotai/kimi-k2-instruct-0905"
|
| 59 |
|
| 60 |
# Grok/xAI API (alternative)
|
| 61 |
grok_api_key: Optional[str] = None
|
|
|
|
| 82 |
|
| 83 |
# Memory service (Mem0)
|
| 84 |
MEM0_COLLECTION: str = "user_memories"
|
| 85 |
+
MEM0_EMBED_MODEL: str = "nomic-embed-text"
|
| 86 |
+
MEM0_CHROMA_DIR: str = "/app/mem0_chroma" # Separate from RAG to avoid singleton conflict
|
| 87 |
+
MEM0_PREFER_GROQ: bool = True
|
| 88 |
+
|
| 89 |
+
# Groq / Mem0 rate-limit mitigation
|
| 90 |
+
# Minimum seconds between consecutive Mem0 LLM calls (prevents TPM exhaustion)
|
| 91 |
+
MEM0_CALL_INTERVAL_SECONDS: float = 3.0
|
| 92 |
+
# Max retries per individual add() call when Groq returns 429
|
| 93 |
+
MEM0_MAX_RETRIES: int = 5
|
| 94 |
+
# Base backoff (seconds) for exponential retry on 429
|
| 95 |
+
MEM0_RETRY_BASE_SECONDS: float = 2.0
|
| 96 |
+
# Proactive delay (seconds) between profile sync batches
|
| 97 |
+
MEM0_BATCH_DELAY_SECONDS: float = 5.0
|
| 98 |
+
# Number of facts per Mem0 batch (larger = fewer API calls)
|
| 99 |
+
MEM0_SYNC_BATCH_SIZE: int = 15
|
| 100 |
|
| 101 |
# Graph service (Graphiti)
|
| 102 |
# Note: Neo4j Community Edition only supports "neo4j" database name
|
|
|
|
| 136 |
logger.info(f" TWILIO_ACCOUNT_SID configured: {bool(settings.TWILIO_ACCOUNT_SID)}")
|
| 137 |
logger.info(f" TWILIO_AUTH_TOKEN configured: {bool(settings.TWILIO_AUTH_TOKEN)}")
|
| 138 |
logger.info(f" TWILIO_FROM_NUMBER: {settings.TWILIO_FROM_NUMBER}")
|
|
|
|
@@ -38,9 +38,12 @@ chromadb>=0.4.0
|
|
| 38 |
sentence-transformers>=2.2.0
|
| 39 |
ollama>=0.1.0
|
| 40 |
google-generativeai>=0.4.0
|
|
|
|
| 41 |
|
| 42 |
# Local MedGemma via Hugging Face
|
| 43 |
-
transformers
|
|
|
|
|
|
|
| 44 |
# CPU-only torch to avoid pulling CUDA/NVIDIA wheels.
|
| 45 |
# Note: Some PyTorch CPU indexes don't publish a `+cpu` local version tag for older releases,
|
| 46 |
# so pin the plain version.
|
|
@@ -53,8 +56,16 @@ huggingface_hub>=0.23.0
|
|
| 53 |
# Memory layer (Mem0)
|
| 54 |
mem0ai>=0.1.0
|
| 55 |
|
| 56 |
-
# Knowledge graph (Graphiti
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# Neo4j driver (required by both Mem0 and Graphiti)
|
| 60 |
neo4j>=5.26.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
sentence-transformers>=2.2.0
|
| 39 |
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.
|
| 46 |
+
transformers>=4.56.0,<5.0.0
|
| 47 |
# CPU-only torch to avoid pulling CUDA/NVIDIA wheels.
|
| 48 |
# Note: Some PyTorch CPU indexes don't publish a `+cpu` local version tag for older releases,
|
| 49 |
# so pin the plain version.
|
|
|
|
| 56 |
# Memory layer (Mem0)
|
| 57 |
mem0ai>=0.1.0
|
| 58 |
|
| 59 |
+
# Knowledge graph (Graphiti)
|
| 60 |
+
# Note: Older `[google-genai]` extra variants pull legacy graph-service deps and
|
| 61 |
+
# can fail resolver on modern environments/architectures.
|
| 62 |
+
graphiti-core>=0.12.0
|
| 63 |
|
| 64 |
# Neo4j driver (required by both Mem0 and Graphiti)
|
| 65 |
neo4j>=5.26.0
|
| 66 |
+
|
| 67 |
+
# Mem0 graph backend dependency (required when graph_store is enabled)
|
| 68 |
+
langchain-neo4j>=0.1.1
|
| 69 |
+
|
| 70 |
+
# Mem0 Neo4j graph backend also requires BM25 helper package
|
| 71 |
+
rank-bm25>=0.2.2
|
|
@@ -1,10 +1,8 @@
|
|
| 1 |
import pytest
|
| 2 |
import uuid
|
| 3 |
from unittest.mock import MagicMock, patch, AsyncMock
|
| 4 |
-
from typing import List, Dict, Any
|
| 5 |
from app.services.assistant_service import AssistantService
|
| 6 |
from app.models import ChatSession, ChatMessage
|
| 7 |
-
from app.schemas import Citation
|
| 8 |
|
| 9 |
# Mock the entire database session
|
| 10 |
@pytest.fixture
|
|
@@ -44,8 +42,8 @@ def assistant_service(mock_db):
|
|
| 44 |
mock_mem.return_value = mem_service
|
| 45 |
|
| 46 |
graph_service = MagicMock()
|
| 47 |
-
graph_service.
|
| 48 |
-
graph_service.
|
| 49 |
mock_graph.return_value = graph_service
|
| 50 |
|
| 51 |
rag_service = MagicMock()
|
|
|
|
| 1 |
import pytest
|
| 2 |
import uuid
|
| 3 |
from unittest.mock import MagicMock, patch, AsyncMock
|
|
|
|
| 4 |
from app.services.assistant_service import AssistantService
|
| 5 |
from app.models import ChatSession, ChatMessage
|
|
|
|
| 6 |
|
| 7 |
# Mock the entire database session
|
| 8 |
@pytest.fixture
|
|
|
|
| 42 |
mock_mem.return_value = mem_service
|
| 43 |
|
| 44 |
graph_service = MagicMock()
|
| 45 |
+
graph_service.search_user = AsyncMock(return_value=["HbA1c -> indicates -> Diabetes"])
|
| 46 |
+
graph_service.add_user_episode = AsyncMock(return_value=None)
|
| 47 |
mock_graph.return_value = graph_service
|
| 48 |
|
| 49 |
rag_service = MagicMock()
|
|
@@ -7,7 +7,6 @@ Uses mocks to avoid needing actual Neo4j/Chroma/Ollama instances.
|
|
| 7 |
import pytest
|
| 8 |
from unittest.mock import AsyncMock, MagicMock, patch
|
| 9 |
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
|
| 12 |
from app.services.assistant_service import AssistantService
|
| 13 |
from app.models import ChatSession, ChatMessage
|
|
@@ -65,7 +64,7 @@ def mock_services():
|
|
| 65 |
|
| 66 |
# Setup Graph Service mocks
|
| 67 |
graph_svc = AsyncMock()
|
| 68 |
-
graph_svc.
|
| 69 |
mock_graph.return_value = graph_svc
|
| 70 |
|
| 71 |
# Setup RAG Service mocks
|
|
@@ -116,7 +115,7 @@ async def test_chat_uses_all_contexts(mock_db_session, mock_services):
|
|
| 116 |
mock_services["memory"].search.assert_called_once_with(message, user_id=str(user_id), limit=5)
|
| 117 |
|
| 118 |
# 3. Verify Graph Search called
|
| 119 |
-
mock_services["graph"].
|
| 120 |
|
| 121 |
# 4. Verify LLM called with combined context
|
| 122 |
call_args = mock_services["llm"].generate.call_args
|
|
@@ -145,6 +144,8 @@ async def test_chat_updates_memory_and_graph(mock_db_session, mock_services):
|
|
| 145 |
assert "Assistant: Based on" in content
|
| 146 |
|
| 147 |
# Verify Graph Update
|
| 148 |
-
mock_services["graph"].
|
| 149 |
-
|
| 150 |
-
assert "
|
|
|
|
|
|
|
|
|
| 7 |
import pytest
|
| 8 |
from unittest.mock import AsyncMock, MagicMock, patch
|
| 9 |
import uuid
|
|
|
|
| 10 |
|
| 11 |
from app.services.assistant_service import AssistantService
|
| 12 |
from app.models import ChatSession, ChatMessage
|
|
|
|
| 64 |
|
| 65 |
# Setup Graph Service mocks
|
| 66 |
graph_svc = AsyncMock()
|
| 67 |
+
graph_svc.search_user.return_value = ["Diabetes relates to High Glucose"]
|
| 68 |
mock_graph.return_value = graph_svc
|
| 69 |
|
| 70 |
# Setup RAG Service mocks
|
|
|
|
| 115 |
mock_services["memory"].search.assert_called_once_with(message, user_id=str(user_id), limit=5)
|
| 116 |
|
| 117 |
# 3. Verify Graph Search called
|
| 118 |
+
mock_services["graph"].search_user.assert_called_once_with(str(user_id), message, limit=5)
|
| 119 |
|
| 120 |
# 4. Verify LLM called with combined context
|
| 121 |
call_args = mock_services["llm"].generate.call_args
|
|
|
|
| 144 |
assert "Assistant: Based on" in content
|
| 145 |
|
| 146 |
# Verify Graph Update
|
| 147 |
+
mock_services["graph"].add_user_episode.assert_called_once()
|
| 148 |
+
_, kwargs = mock_services["graph"].add_user_episode.call_args
|
| 149 |
+
assert kwargs["user_id"] == str(user_id)
|
| 150 |
+
assert kwargs["source"] == "user_chat"
|
| 151 |
+
assert "User asked: I feel tired" in kwargs["content"]
|
|
@@ -22,12 +22,13 @@ async def test_add_episode(graph_service):
|
|
| 22 |
await graph_service.add_episode(content, source)
|
| 23 |
|
| 24 |
# Verify
|
| 25 |
-
graph_service.client.add_episode.
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
@pytest.mark.asyncio
|
| 33 |
async def test_add_observations(graph_service):
|
|
@@ -40,12 +41,13 @@ async def test_add_observations(graph_service):
|
|
| 40 |
|
| 41 |
# Verify
|
| 42 |
expected_content = "Medical System Observations:\n- BP is 140/90\n- HR is 80"
|
| 43 |
-
graph_service.client.add_episode.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 49 |
|
| 50 |
@pytest.mark.asyncio
|
| 51 |
async def test_search_graph(graph_service):
|
|
@@ -66,7 +68,10 @@ async def test_search_graph(graph_service):
|
|
| 66 |
results = await graph_service.search(query)
|
| 67 |
|
| 68 |
# Verify
|
| 69 |
-
graph_service.client.search.
|
|
|
|
|
|
|
|
|
|
| 70 |
assert results == ["Patient -> HAS_CONDITION -> Hypertension"]
|
| 71 |
|
| 72 |
@pytest.mark.asyncio
|
|
@@ -76,19 +81,18 @@ async def test_initialization_flow():
|
|
| 76 |
|
| 77 |
# We need to mock the imports in graph_service
|
| 78 |
with patch('app.services.graph_service.GRAPHITI_AVAILABLE', True), \
|
| 79 |
-
patch('app.services.graph_service.Neo4jDriver') as MockDriver, \
|
| 80 |
patch('app.services.graph_service.LLMConfig') as MockConfig, \
|
| 81 |
patch('app.services.graph_service.OpenAIClient') as MockClient, \
|
| 82 |
patch('app.services.graph_service.Graphiti') as MockGraphiti:
|
| 83 |
|
|
|
|
| 84 |
service = GraphService()
|
| 85 |
|
| 86 |
await service.initialize()
|
| 87 |
|
| 88 |
# Verify initializations
|
| 89 |
-
MockDriver.assert_called_once()
|
| 90 |
MockConfig.assert_called_once()
|
| 91 |
-
MockClient.
|
| 92 |
MockGraphiti.assert_called_once()
|
| 93 |
|
| 94 |
# Verify build_indices was called
|
|
|
|
| 22 |
await graph_service.add_episode(content, source)
|
| 23 |
|
| 24 |
# Verify
|
| 25 |
+
graph_service.client.add_episode.assert_called_once()
|
| 26 |
+
kwargs = graph_service.client.add_episode.call_args.kwargs
|
| 27 |
+
assert kwargs["name"] == f"Episode from {source}"
|
| 28 |
+
assert kwargs["episode_body"] == content
|
| 29 |
+
assert kwargs["source_description"] == source
|
| 30 |
+
assert "reference_time" in kwargs
|
| 31 |
+
assert kwargs["group_id"] is None
|
| 32 |
|
| 33 |
@pytest.mark.asyncio
|
| 34 |
async def test_add_observations(graph_service):
|
|
|
|
| 41 |
|
| 42 |
# Verify
|
| 43 |
expected_content = "Medical System Observations:\n- BP is 140/90\n- HR is 80"
|
| 44 |
+
graph_service.client.add_episode.assert_called_once()
|
| 45 |
+
kwargs = graph_service.client.add_episode.call_args.kwargs
|
| 46 |
+
assert kwargs["name"] == f"Episode from {source}"
|
| 47 |
+
assert kwargs["episode_body"] == expected_content
|
| 48 |
+
assert kwargs["source_description"] == source
|
| 49 |
+
assert "reference_time" in kwargs
|
| 50 |
+
assert kwargs["group_id"] is None
|
| 51 |
|
| 52 |
@pytest.mark.asyncio
|
| 53 |
async def test_search_graph(graph_service):
|
|
|
|
| 68 |
results = await graph_service.search(query)
|
| 69 |
|
| 70 |
# Verify
|
| 71 |
+
graph_service.client.search.assert_called_once()
|
| 72 |
+
args, kwargs = graph_service.client.search.call_args
|
| 73 |
+
assert args[0] == query
|
| 74 |
+
assert kwargs.get("num_results", kwargs.get("limit")) == 10
|
| 75 |
assert results == ["Patient -> HAS_CONDITION -> Hypertension"]
|
| 76 |
|
| 77 |
@pytest.mark.asyncio
|
|
|
|
| 81 |
|
| 82 |
# We need to mock the imports in graph_service
|
| 83 |
with patch('app.services.graph_service.GRAPHITI_AVAILABLE', True), \
|
|
|
|
| 84 |
patch('app.services.graph_service.LLMConfig') as MockConfig, \
|
| 85 |
patch('app.services.graph_service.OpenAIClient') as MockClient, \
|
| 86 |
patch('app.services.graph_service.Graphiti') as MockGraphiti:
|
| 87 |
|
| 88 |
+
MockGraphiti.return_value.build_indices_and_constraints = AsyncMock(return_value=None)
|
| 89 |
service = GraphService()
|
| 90 |
|
| 91 |
await service.initialize()
|
| 92 |
|
| 93 |
# Verify initializations
|
|
|
|
| 94 |
MockConfig.assert_called_once()
|
| 95 |
+
MockClient.assert_called_once_with(config=MockConfig.return_value, max_tokens=2048)
|
| 96 |
MockGraphiti.assert_called_once()
|
| 97 |
|
| 98 |
# Verify build_indices was called
|
|
@@ -15,7 +15,7 @@ class TestGraphRoutes:
|
|
| 15 |
"""Create a mock graph service."""
|
| 16 |
mock_service = MagicMock()
|
| 17 |
mock_service.client = MagicMock() # Service is available
|
| 18 |
-
mock_service.
|
| 19 |
"High LDL -> leads_to -> Cardiovascular Risk",
|
| 20 |
"Diabetes -> requires -> Blood Sugar Monitoring",
|
| 21 |
"Exercise -> improves -> Cardiovascular Health",
|
|
@@ -51,7 +51,11 @@ class TestGraphRoutes:
|
|
| 51 |
assert result.available is True
|
| 52 |
assert result.count == 3
|
| 53 |
assert len(result.facts) == 3
|
| 54 |
-
mock_graph_service.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
@pytest.mark.asyncio
|
| 57 |
async def test_get_user_graph_facts_unavailable(self, mock_graph_service_unavailable, mock_user):
|
|
@@ -82,7 +86,8 @@ class TestGraphRoutes:
|
|
| 82 |
|
| 83 |
assert result.available is True
|
| 84 |
assert result.count == 3
|
| 85 |
-
mock_graph_service.
|
|
|
|
| 86 |
query="cardiovascular",
|
| 87 |
limit=10
|
| 88 |
)
|
|
@@ -128,7 +133,7 @@ class TestGraphRoutes:
|
|
| 128 |
"""Test error handling in graph routes."""
|
| 129 |
mock_service = MagicMock()
|
| 130 |
mock_service.client = MagicMock() # Service available
|
| 131 |
-
mock_service.
|
| 132 |
|
| 133 |
with patch('app.routes.graph.get_graph_service', return_value=mock_service):
|
| 134 |
from app.routes.graph import get_user_graph_facts
|
|
|
|
| 15 |
"""Create a mock graph service."""
|
| 16 |
mock_service = MagicMock()
|
| 17 |
mock_service.client = MagicMock() # Service is available
|
| 18 |
+
mock_service.search_user = AsyncMock(return_value=[
|
| 19 |
"High LDL -> leads_to -> Cardiovascular Risk",
|
| 20 |
"Diabetes -> requires -> Blood Sugar Monitoring",
|
| 21 |
"Exercise -> improves -> Cardiovascular Health",
|
|
|
|
| 51 |
assert result.available is True
|
| 52 |
assert result.count == 3
|
| 53 |
assert len(result.facts) == 3
|
| 54 |
+
mock_graph_service.search_user.assert_called_once_with(
|
| 55 |
+
user_id="test-user-123",
|
| 56 |
+
query="health conditions",
|
| 57 |
+
limit=20,
|
| 58 |
+
)
|
| 59 |
|
| 60 |
@pytest.mark.asyncio
|
| 61 |
async def test_get_user_graph_facts_unavailable(self, mock_graph_service_unavailable, mock_user):
|
|
|
|
| 86 |
|
| 87 |
assert result.available is True
|
| 88 |
assert result.count == 3
|
| 89 |
+
mock_graph_service.search_user.assert_called_once_with(
|
| 90 |
+
user_id="test-user-123",
|
| 91 |
query="cardiovascular",
|
| 92 |
limit=10
|
| 93 |
)
|
|
|
|
| 133 |
"""Test error handling in graph routes."""
|
| 134 |
mock_service = MagicMock()
|
| 135 |
mock_service.client = MagicMock() # Service available
|
| 136 |
+
mock_service.search_user = AsyncMock(side_effect=Exception("Neo4j connection error"))
|
| 137 |
|
| 138 |
with patch('app.routes.graph.get_graph_service', return_value=mock_service):
|
| 139 |
from app.routes.graph import get_user_graph_facts
|
|
@@ -106,6 +106,19 @@ class TestMemoryRoutes:
|
|
| 106 |
assert result.deleted_count == 1
|
| 107 |
mock_memory_service.delete.assert_called_once_with("mem-1")
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
@pytest.mark.asyncio
|
| 110 |
async def test_delete_all_memories_success(self, mock_memory_service, mock_user):
|
| 111 |
"""Test successful deletion of all memories."""
|
|
|
|
| 106 |
assert result.deleted_count == 1
|
| 107 |
mock_memory_service.delete.assert_called_once_with("mem-1")
|
| 108 |
|
| 109 |
+
@pytest.mark.asyncio
|
| 110 |
+
async def test_delete_memory_rejects_other_user_id(self, mock_memory_service, mock_user):
|
| 111 |
+
"""Delete should be rejected if memory ID is not found in current user's list."""
|
| 112 |
+
with patch('app.routes.memory.get_memory_service', return_value=mock_memory_service):
|
| 113 |
+
from app.routes.memory import delete_memory
|
| 114 |
+
|
| 115 |
+
result = await delete_memory(memory_id="not-owned-id", current_user=mock_user)
|
| 116 |
+
|
| 117 |
+
assert result.success is False
|
| 118 |
+
assert result.deleted_count == 0
|
| 119 |
+
assert "not found" in (result.message or "").lower()
|
| 120 |
+
mock_memory_service.delete.assert_not_called()
|
| 121 |
+
|
| 122 |
@pytest.mark.asyncio
|
| 123 |
async def test_delete_all_memories_success(self, mock_memory_service, mock_user):
|
| 124 |
"""Test successful deletion of all memories."""
|
|
@@ -49,6 +49,8 @@ services:
|
|
| 49 |
- paddle_models:/root/.paddleocr
|
| 50 |
# Persist ChromaDB vector store
|
| 51 |
- chroma_data:/app/chroma_db
|
|
|
|
|
|
|
| 52 |
# Persist Hugging Face model/cache locally
|
| 53 |
- hf_cache:/root/.cache/huggingface
|
| 54 |
environment:
|
|
@@ -68,6 +70,12 @@ services:
|
|
| 68 |
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
| 69 |
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
| 70 |
- GOOGLE_PLACES_API_KEY=${GOOGLE_PLACES_API_KEY:-}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# ElevenLabs TTS Configuration
|
| 72 |
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY:-}
|
| 73 |
- ELEVENLABS_VOICE_ID=${ELEVENLABS_VOICE_ID:-21m00Tcm4TlvDq8ikWAM}
|
|
@@ -86,7 +94,17 @@ services:
|
|
| 86 |
- NEO4J_USER=${NEO4J_USER:-neo4j}
|
| 87 |
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-changeme}
|
| 88 |
- MEM0_COLLECTION=${MEM0_COLLECTION:-user_memories}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
- GRAPHITI_DATABASE=${GRAPHITI_DATABASE:-neo4j}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
# SMS Configuration (Twilio or mock mode)
|
| 91 |
- SMS_MODE=${SMS_MODE:-mock}
|
| 92 |
- SMS_TEST_TO_NUMBER=${SMS_TEST_TO_NUMBER:-}
|
|
@@ -117,6 +135,12 @@ services:
|
|
| 117 |
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
|
| 118 |
NEO4J_PLUGINS: '["apoc"]'
|
| 119 |
NEO4J_dbms_security_procedures_unrestricted: apoc.*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
ports:
|
| 121 |
- "7474:7474" # Browser UI
|
| 122 |
- "7687:7687" # Bolt protocol
|
|
@@ -153,6 +177,7 @@ volumes:
|
|
| 153 |
postgres_data:
|
| 154 |
paddle_models:
|
| 155 |
chroma_data:
|
|
|
|
| 156 |
hf_cache:
|
| 157 |
neo4j_data:
|
| 158 |
neo4j_logs:
|
|
|
|
| 49 |
- paddle_models:/root/.paddleocr
|
| 50 |
# Persist ChromaDB vector store
|
| 51 |
- chroma_data:/app/chroma_db
|
| 52 |
+
# Persist Mem0's separate ChromaDB store
|
| 53 |
+
- mem0_chroma:/app/mem0_chroma
|
| 54 |
# Persist Hugging Face model/cache locally
|
| 55 |
- hf_cache:/root/.cache/huggingface
|
| 56 |
environment:
|
|
|
|
| 70 |
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
| 71 |
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
| 72 |
- GOOGLE_PLACES_API_KEY=${GOOGLE_PLACES_API_KEY:-}
|
| 73 |
+
# OpenRouter (PRIMARY LLM provider)
|
| 74 |
+
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
| 75 |
+
- OPENROUTER_BASE_URL=${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
|
| 76 |
+
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-openrouter/pony-alpha}
|
| 77 |
+
- OPENROUTER_FALLBACK_MODEL=${OPENROUTER_FALLBACK_MODEL:-upstage/solar-pro-3:free}
|
| 78 |
+
- OPENROUTER_TIMEOUT=${OPENROUTER_TIMEOUT:-120}
|
| 79 |
# ElevenLabs TTS Configuration
|
| 80 |
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY:-}
|
| 81 |
- ELEVENLABS_VOICE_ID=${ELEVENLABS_VOICE_ID:-21m00Tcm4TlvDq8ikWAM}
|
|
|
|
| 94 |
- NEO4J_USER=${NEO4J_USER:-neo4j}
|
| 95 |
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-changeme}
|
| 96 |
- MEM0_COLLECTION=${MEM0_COLLECTION:-user_memories}
|
| 97 |
+
- MEM0_EMBED_MODEL=${MEM0_EMBED_MODEL:-nomic-embed-text}
|
| 98 |
+
- MEM0_GROQ_MODEL=${MEM0_GROQ_MODEL:-llama-3.1-8b-instant}
|
| 99 |
+
- MEM0_PREFER_GROQ=true
|
| 100 |
+
- GRAPHITI_GROQ_MODEL=${GRAPHITI_GROQ_MODEL:-moonshotai/kimi-k2-instruct-0905}
|
| 101 |
- GRAPHITI_DATABASE=${GRAPHITI_DATABASE:-neo4j}
|
| 102 |
+
# Groq rate-limit mitigation (tune for your Groq plan)
|
| 103 |
+
- MEM0_CALL_INTERVAL_SECONDS=${MEM0_CALL_INTERVAL_SECONDS:-3.0}
|
| 104 |
+
- MEM0_MAX_RETRIES=${MEM0_MAX_RETRIES:-5}
|
| 105 |
+
- MEM0_RETRY_BASE_SECONDS=${MEM0_RETRY_BASE_SECONDS:-2.0}
|
| 106 |
+
- MEM0_BATCH_DELAY_SECONDS=${MEM0_BATCH_DELAY_SECONDS:-5.0}
|
| 107 |
+
- MEM0_SYNC_BATCH_SIZE=${MEM0_SYNC_BATCH_SIZE:-15}
|
| 108 |
# SMS Configuration (Twilio or mock mode)
|
| 109 |
- SMS_MODE=${SMS_MODE:-mock}
|
| 110 |
- SMS_TEST_TO_NUMBER=${SMS_TEST_TO_NUMBER:-}
|
|
|
|
| 135 |
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
|
| 136 |
NEO4J_PLUGINS: '["apoc"]'
|
| 137 |
NEO4J_dbms_security_procedures_unrestricted: apoc.*
|
| 138 |
+
# ---- Performance tuning (prevents default tiny-heap lag) ----
|
| 139 |
+
NEO4J_server_memory_heap_initial__size: 512m
|
| 140 |
+
NEO4J_server_memory_heap_max__size: 1G
|
| 141 |
+
NEO4J_server_memory_pagecache_size: 512m
|
| 142 |
+
# Disable unnecessary features for faster startup
|
| 143 |
+
NEO4J_dbms_usage__report_enabled: "false"
|
| 144 |
ports:
|
| 145 |
- "7474:7474" # Browser UI
|
| 146 |
- "7687:7687" # Bolt protocol
|
|
|
|
| 177 |
postgres_data:
|
| 178 |
paddle_models:
|
| 179 |
chroma_data:
|
| 180 |
+
mem0_chroma:
|
| 181 |
hf_cache:
|
| 182 |
neo4j_data:
|
| 183 |
neo4j_logs:
|
|
@@ -75,6 +75,12 @@
|
|
| 75 |
background: var(--dash-surface-hover, #ffffff);
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
.health-graph-controls {
|
| 79 |
display: flex;
|
| 80 |
align-items: center;
|
|
@@ -132,6 +138,31 @@
|
|
| 132 |
color: white;
|
| 133 |
}
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
/* States */
|
| 136 |
.health-graph-loading,
|
| 137 |
.health-graph-empty,
|
|
@@ -178,6 +209,47 @@
|
|
| 178 |
font-size: 0.875rem;
|
| 179 |
}
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
/* Canvas */
|
| 182 |
.health-graph-canvas-wrapper {
|
| 183 |
position: relative;
|
|
@@ -307,6 +379,177 @@
|
|
| 307 |
color: var(--dash-accent-dark, #4a7c59);
|
| 308 |
}
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
/* Spinner animation */
|
| 311 |
.spinning {
|
| 312 |
animation: spin 1s linear infinite;
|
|
@@ -330,6 +573,11 @@
|
|
| 330 |
gap: var(--dash-spacing-sm, 0.75rem);
|
| 331 |
}
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
.health-graph-canvas-wrapper {
|
| 334 |
margin: 0 var(--dash-spacing-md, 1rem) var(--dash-spacing-md, 1rem);
|
| 335 |
}
|
|
|
|
| 75 |
background: var(--dash-surface-hover, #ffffff);
|
| 76 |
}
|
| 77 |
|
| 78 |
+
.health-graph-actions {
|
| 79 |
+
display: flex;
|
| 80 |
+
align-items: center;
|
| 81 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
.health-graph-controls {
|
| 85 |
display: flex;
|
| 86 |
align-items: center;
|
|
|
|
| 138 |
color: white;
|
| 139 |
}
|
| 140 |
|
| 141 |
+
.health-graph-sync {
|
| 142 |
+
display: inline-flex;
|
| 143 |
+
align-items: center;
|
| 144 |
+
gap: 0.4rem;
|
| 145 |
+
padding: 0.5rem 0.75rem;
|
| 146 |
+
border-radius: var(--dash-radius-md, 0.75rem);
|
| 147 |
+
border: 1px solid var(--dash-accent-light, #8fb199);
|
| 148 |
+
background: var(--dash-accent-pale, #e8f3eb);
|
| 149 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 150 |
+
font-size: 0.8125rem;
|
| 151 |
+
font-weight: 600;
|
| 152 |
+
cursor: pointer;
|
| 153 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.health-graph-sync:hover:not(:disabled) {
|
| 157 |
+
background: var(--dash-accent-light, #8fb199);
|
| 158 |
+
color: white;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.health-graph-sync:disabled {
|
| 162 |
+
opacity: 0.6;
|
| 163 |
+
cursor: not-allowed;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
/* States */
|
| 167 |
.health-graph-loading,
|
| 168 |
.health-graph-empty,
|
|
|
|
| 209 |
font-size: 0.875rem;
|
| 210 |
}
|
| 211 |
|
| 212 |
+
.health-graph-note {
|
| 213 |
+
display: flex;
|
| 214 |
+
align-items: center;
|
| 215 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 216 |
+
padding: 0.75rem var(--dash-spacing-xl, 2rem);
|
| 217 |
+
background: rgba(107, 145, 117, 0.12);
|
| 218 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 219 |
+
font-size: 0.875rem;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.health-graph-empty-note {
|
| 223 |
+
margin-top: var(--dash-spacing-sm, 0.75rem);
|
| 224 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 225 |
+
font-size: 0.8125rem;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.health-graph-empty-sync {
|
| 229 |
+
margin-top: var(--dash-spacing-md, 1rem);
|
| 230 |
+
display: inline-flex;
|
| 231 |
+
align-items: center;
|
| 232 |
+
gap: 0.4rem;
|
| 233 |
+
padding: 0.625rem 1rem;
|
| 234 |
+
border-radius: var(--dash-radius-md, 0.75rem);
|
| 235 |
+
border: 1px solid var(--dash-accent-light, #8fb199);
|
| 236 |
+
background: var(--dash-accent, #6b9175);
|
| 237 |
+
color: white;
|
| 238 |
+
font-size: 0.875rem;
|
| 239 |
+
font-weight: 600;
|
| 240 |
+
cursor: pointer;
|
| 241 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.health-graph-empty-sync:hover:not(:disabled) {
|
| 245 |
+
background: var(--dash-accent-dark, #4a7c59);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.health-graph-empty-sync:disabled {
|
| 249 |
+
opacity: 0.6;
|
| 250 |
+
cursor: not-allowed;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
/* Canvas */
|
| 254 |
.health-graph-canvas-wrapper {
|
| 255 |
position: relative;
|
|
|
|
| 379 |
color: var(--dash-accent-dark, #4a7c59);
|
| 380 |
}
|
| 381 |
|
| 382 |
+
/* ============================================================================
|
| 383 |
+
AI Insights Panel Styles
|
| 384 |
+
============================================================================ */
|
| 385 |
+
|
| 386 |
+
.health-graph-insights {
|
| 387 |
+
margin: 0 var(--dash-spacing-xl, 2rem) var(--dash-spacing-lg, 1.5rem);
|
| 388 |
+
padding: var(--dash-spacing-lg, 1.5rem);
|
| 389 |
+
background: linear-gradient(135deg, #faf8f4 0%, #f5f1e8 100%);
|
| 390 |
+
border-radius: var(--dash-radius-lg, 1rem);
|
| 391 |
+
border: 1px solid var(--dash-border, #e5dfd5);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.insights-header {
|
| 395 |
+
display: flex;
|
| 396 |
+
align-items: center;
|
| 397 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 398 |
+
margin-bottom: var(--dash-spacing-md, 1rem);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.insights-icon {
|
| 402 |
+
color: var(--dash-accent, #6b9175);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.insights-title {
|
| 406 |
+
font-size: 0.9375rem;
|
| 407 |
+
font-weight: 600;
|
| 408 |
+
color: var(--dash-text, #2d2d2d);
|
| 409 |
+
font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.insights-buttons {
|
| 413 |
+
display: flex;
|
| 414 |
+
flex-wrap: wrap;
|
| 415 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.insight-button {
|
| 419 |
+
display: inline-flex;
|
| 420 |
+
align-items: center;
|
| 421 |
+
gap: 0.4rem;
|
| 422 |
+
padding: 0.5rem 0.875rem;
|
| 423 |
+
border-radius: var(--dash-radius-md, 0.75rem);
|
| 424 |
+
border: 1px solid transparent;
|
| 425 |
+
font-size: 0.8125rem;
|
| 426 |
+
font-weight: 600;
|
| 427 |
+
cursor: pointer;
|
| 428 |
+
transition: all var(--dash-transition-base, 250ms);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.insight-button:disabled {
|
| 432 |
+
opacity: 0.6;
|
| 433 |
+
cursor: not-allowed;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* Temporal - Blue theme */
|
| 437 |
+
.insight-button-temporal {
|
| 438 |
+
background: linear-gradient(135deg, #d4e5f7 0%, #c4d8f0 100%);
|
| 439 |
+
color: #3d6a9f;
|
| 440 |
+
border-color: #a8c8e8;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.insight-button-temporal:hover:not(:disabled),
|
| 444 |
+
.insight-button-temporal.active {
|
| 445 |
+
background: linear-gradient(135deg, #7a9cc6 0%, #5a8abf 100%);
|
| 446 |
+
color: white;
|
| 447 |
+
border-color: #5a8abf;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/* Relationships - Green theme */
|
| 451 |
+
.insight-button-relationships {
|
| 452 |
+
background: linear-gradient(135deg, #e8f3eb 0%, #d4e8d9 100%);
|
| 453 |
+
color: #4a7c59;
|
| 454 |
+
border-color: #8fb199;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.insight-button-relationships:hover:not(:disabled),
|
| 458 |
+
.insight-button-relationships.active {
|
| 459 |
+
background: linear-gradient(135deg, #6b9175 0%, #4a7c59 100%);
|
| 460 |
+
color: white;
|
| 461 |
+
border-color: #4a7c59;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/* Contradictions - Amber theme */
|
| 465 |
+
.insight-button-contradictions {
|
| 466 |
+
background: linear-gradient(135deg, #fef3d7 0%, #fce8b2 100%);
|
| 467 |
+
color: #a07d28;
|
| 468 |
+
border-color: #d9a962;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.insight-button-contradictions:hover:not(:disabled),
|
| 472 |
+
.insight-button-contradictions.active {
|
| 473 |
+
background: linear-gradient(135deg, #d9a962 0%, #c49430 100%);
|
| 474 |
+
color: white;
|
| 475 |
+
border-color: #c49430;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.insights-content {
|
| 479 |
+
margin-top: var(--dash-spacing-md, 1rem);
|
| 480 |
+
padding: var(--dash-spacing-md, 1rem);
|
| 481 |
+
background: white;
|
| 482 |
+
border-radius: var(--dash-radius-md, 0.75rem);
|
| 483 |
+
border: 1px solid var(--dash-border-light, #f0ebe3);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.insights-loading {
|
| 487 |
+
display: flex;
|
| 488 |
+
align-items: center;
|
| 489 |
+
justify-content: center;
|
| 490 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 491 |
+
padding: var(--dash-spacing-lg, 1.5rem);
|
| 492 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 493 |
+
font-size: 0.9rem;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.insights-error {
|
| 497 |
+
display: flex;
|
| 498 |
+
align-items: center;
|
| 499 |
+
gap: var(--dash-spacing-sm, 0.75rem);
|
| 500 |
+
padding: var(--dash-spacing-md, 1rem);
|
| 501 |
+
background: rgba(200, 90, 84, 0.1);
|
| 502 |
+
border-radius: var(--dash-radius-sm, 0.5rem);
|
| 503 |
+
color: var(--dash-danger, #c85a54);
|
| 504 |
+
font-size: 0.875rem;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.insights-text {
|
| 508 |
+
line-height: 1.7;
|
| 509 |
+
color: var(--dash-text, #2d2d2d);
|
| 510 |
+
font-size: 0.9375rem;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.insights-text p {
|
| 514 |
+
margin: 0 0 0.75rem 0;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.insights-text p:last-child {
|
| 518 |
+
margin-bottom: 0;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.insights-sources {
|
| 522 |
+
margin-top: var(--dash-spacing-md, 1rem);
|
| 523 |
+
padding-top: var(--dash-spacing-sm, 0.75rem);
|
| 524 |
+
border-top: 1px solid var(--dash-border-light, #f0ebe3);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.insights-sources summary {
|
| 528 |
+
display: flex;
|
| 529 |
+
align-items: center;
|
| 530 |
+
gap: 0.35rem;
|
| 531 |
+
font-size: 0.8125rem;
|
| 532 |
+
color: var(--dash-text-muted, #999999);
|
| 533 |
+
cursor: pointer;
|
| 534 |
+
user-select: none;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.insights-sources summary:hover {
|
| 538 |
+
color: var(--dash-accent-dark, #4a7c59);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.insights-sources ul {
|
| 542 |
+
margin: var(--dash-spacing-sm, 0.75rem) 0 0;
|
| 543 |
+
padding: 0 0 0 1.25rem;
|
| 544 |
+
font-size: 0.8125rem;
|
| 545 |
+
color: var(--dash-text-secondary, #6b6b6b);
|
| 546 |
+
line-height: 1.6;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.insights-sources li {
|
| 550 |
+
margin-bottom: 0.35rem;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
/* Spinner animation */
|
| 554 |
.spinning {
|
| 555 |
animation: spin 1s linear infinite;
|
|
|
|
| 573 |
gap: var(--dash-spacing-sm, 0.75rem);
|
| 574 |
}
|
| 575 |
|
| 576 |
+
.health-graph-actions {
|
| 577 |
+
width: 100%;
|
| 578 |
+
justify-content: flex-end;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
.health-graph-canvas-wrapper {
|
| 582 |
margin: 0 var(--dash-spacing-md, 1rem) var(--dash-spacing-md, 1rem);
|
| 583 |
}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import {
|
| 4 |
Network,
|
|
@@ -11,6 +11,10 @@ import {
|
|
| 11 |
ZoomOut,
|
| 12 |
Maximize2,
|
| 13 |
Info,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
} from 'lucide-react';
|
| 15 |
import { API_BASE_URL } from '../config/api';
|
| 16 |
import './HealthGraph.css';
|
|
@@ -39,6 +43,22 @@ interface GraphDataResponse {
|
|
| 39 |
message: string | null;
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
interface HealthGraphProps {
|
| 43 |
authToken?: string;
|
| 44 |
apiBaseUrl?: string;
|
|
@@ -55,6 +75,8 @@ const nodeColors: Record<string, string> = {
|
|
| 55 |
entity: '#999999', // dash-text-muted (gray)
|
| 56 |
};
|
| 57 |
|
|
|
|
|
|
|
| 58 |
const HealthGraph: React.FC<HealthGraphProps> = ({
|
| 59 |
authToken,
|
| 60 |
apiBaseUrl = API_BASE_URL,
|
|
@@ -62,13 +84,22 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 62 |
}) => {
|
| 63 |
const [graphData, setGraphData] = useState<GraphDataResponse | null>(null);
|
| 64 |
const [loading, setLoading] = useState(false);
|
|
|
|
| 65 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 66 |
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
| 67 |
const [available, setAvailable] = useState(true);
|
| 68 |
const [zoom, setZoom] = useState(1);
|
| 69 |
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
| 70 |
const svgRef = useRef<SVGSVGElement>(null);
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
const fetchGraphData = useCallback(async () => {
|
| 73 |
if (!authToken) {
|
| 74 |
setError('Authentication required');
|
|
@@ -103,6 +134,98 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 103 |
}
|
| 104 |
}, [authToken, apiBaseUrl]);
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
useEffect(() => {
|
| 107 |
if (!collapsed && authToken) {
|
| 108 |
fetchGraphData();
|
|
@@ -110,7 +233,7 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 110 |
}, [collapsed, authToken, fetchGraphData]);
|
| 111 |
|
| 112 |
// Simple force-directed layout calculation (simplified)
|
| 113 |
-
const calculateLayout = useCallback((nodes: GraphNode[]
|
| 114 |
const width = 600;
|
| 115 |
const height = 400;
|
| 116 |
const centerX = width / 2;
|
|
@@ -154,9 +277,10 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 154 |
return nodePositions;
|
| 155 |
}, []);
|
| 156 |
|
| 157 |
-
const nodePositions =
|
| 158 |
-
? calculateLayout(graphData.nodes
|
| 159 |
-
|
|
|
|
| 160 |
|
| 161 |
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.2, 2));
|
| 162 |
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.2, 0.5));
|
|
@@ -210,8 +334,22 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 210 |
) : !graphData || graphData.nodes.length === 0 ? (
|
| 211 |
<div className="health-graph-empty">
|
| 212 |
<Sparkles size={32} />
|
| 213 |
-
<p>No
|
| 214 |
-
<span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
</div>
|
| 216 |
) : (
|
| 217 |
<>
|
|
@@ -228,13 +366,102 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 228 |
<Maximize2 size={16} />
|
| 229 |
</button>
|
| 230 |
</div>
|
| 231 |
-
<
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
</div>
|
| 239 |
|
| 240 |
{error && (
|
|
@@ -254,8 +481,8 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
|
|
| 254 |
{/* Relationship lines */}
|
| 255 |
<g className="relationships">
|
| 256 |
{graphData.relationships.map((rel, index) => {
|
| 257 |
-
const sourcePos = nodePositions[rel.source
|
| 258 |
-
const targetPos = nodePositions[rel.target
|
| 259 |
|
| 260 |
if (!sourcePos || !targetPos) return null;
|
| 261 |
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import {
|
| 4 |
Network,
|
|
|
|
| 11 |
ZoomOut,
|
| 12 |
Maximize2,
|
| 13 |
Info,
|
| 14 |
+
TrendingUp,
|
| 15 |
+
GitBranch,
|
| 16 |
+
AlertTriangle,
|
| 17 |
+
Loader2,
|
| 18 |
} from 'lucide-react';
|
| 19 |
import { API_BASE_URL } from '../config/api';
|
| 20 |
import './HealthGraph.css';
|
|
|
|
| 43 |
message: string | null;
|
| 44 |
}
|
| 45 |
|
| 46 |
+
interface InsightResponse {
|
| 47 |
+
insight_type: string;
|
| 48 |
+
content: string;
|
| 49 |
+
sources: string[];
|
| 50 |
+
available: boolean;
|
| 51 |
+
message: string | null;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
type InsightType = 'temporal' | 'relationships' | 'contradictions';
|
| 55 |
+
|
| 56 |
+
const INSIGHT_BUTTONS: { type: InsightType; icon: typeof TrendingUp; label: string; description: string }[] = [
|
| 57 |
+
{ type: 'temporal', icon: TrendingUp, label: 'Timeline Analysis', description: 'How have my metrics changed?' },
|
| 58 |
+
{ type: 'relationships', icon: GitBranch, label: 'Health Connections', description: 'What\'s connected in my health data?' },
|
| 59 |
+
{ type: 'contradictions', icon: AlertTriangle, label: 'Data Conflicts', description: 'Any inconsistencies in my records?' },
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
interface HealthGraphProps {
|
| 63 |
authToken?: string;
|
| 64 |
apiBaseUrl?: string;
|
|
|
|
| 75 |
entity: '#999999', // dash-text-muted (gray)
|
| 76 |
};
|
| 77 |
|
| 78 |
+
const normalizeNodeId = (value: string): string => value.trim().toLowerCase().replace(/\s+/g, '_');
|
| 79 |
+
|
| 80 |
const HealthGraph: React.FC<HealthGraphProps> = ({
|
| 81 |
authToken,
|
| 82 |
apiBaseUrl = API_BASE_URL,
|
|
|
|
| 84 |
}) => {
|
| 85 |
const [graphData, setGraphData] = useState<GraphDataResponse | null>(null);
|
| 86 |
const [loading, setLoading] = useState(false);
|
| 87 |
+
const [syncing, setSyncing] = useState(false);
|
| 88 |
const [error, setError] = useState<string | null>(null);
|
| 89 |
+
const [syncNotice, setSyncNotice] = useState<string | null>(null);
|
| 90 |
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
| 91 |
const [available, setAvailable] = useState(true);
|
| 92 |
const [zoom, setZoom] = useState(1);
|
| 93 |
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
| 94 |
const svgRef = useRef<SVGSVGElement>(null);
|
| 95 |
|
| 96 |
+
// Insights state
|
| 97 |
+
const [insightLoading, setInsightLoading] = useState(false);
|
| 98 |
+
const [insightContent, setInsightContent] = useState<string | null>(null);
|
| 99 |
+
const [insightSources, setInsightSources] = useState<string[]>([]);
|
| 100 |
+
const [activeInsightType, setActiveInsightType] = useState<InsightType | null>(null);
|
| 101 |
+
const [insightError, setInsightError] = useState<string | null>(null);
|
| 102 |
+
|
| 103 |
const fetchGraphData = useCallback(async () => {
|
| 104 |
if (!authToken) {
|
| 105 |
setError('Authentication required');
|
|
|
|
| 134 |
}
|
| 135 |
}, [authToken, apiBaseUrl]);
|
| 136 |
|
| 137 |
+
// Generate AI insight from graph data
|
| 138 |
+
const generateInsight = useCallback(async (insightType: InsightType) => {
|
| 139 |
+
if (!authToken) {
|
| 140 |
+
setInsightError('Authentication required');
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
setInsightLoading(true);
|
| 145 |
+
setInsightError(null);
|
| 146 |
+
setInsightContent(null);
|
| 147 |
+
setActiveInsightType(insightType);
|
| 148 |
+
|
| 149 |
+
try {
|
| 150 |
+
const response = await fetch(`${apiBaseUrl}/api/graph/insights`, {
|
| 151 |
+
method: 'POST',
|
| 152 |
+
headers: {
|
| 153 |
+
Authorization: `Bearer ${authToken}`,
|
| 154 |
+
'Content-Type': 'application/json',
|
| 155 |
+
},
|
| 156 |
+
body: JSON.stringify({ insight_type: insightType, context_limit: 10 }),
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
if (!response.ok) {
|
| 160 |
+
throw new Error(`Failed to generate insight: ${response.status}`);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const data: InsightResponse = await response.json();
|
| 164 |
+
|
| 165 |
+
if (!data.available) {
|
| 166 |
+
throw new Error(data.message || 'Service not available');
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (data.message && !data.content) {
|
| 170 |
+
throw new Error(data.message);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (!data.content || data.content.trim() === '') {
|
| 174 |
+
setInsightError('No insight generated. Try adding more health data.');
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
setInsightContent(data.content);
|
| 179 |
+
setInsightSources(data.sources || []);
|
| 180 |
+
} catch (err) {
|
| 181 |
+
setInsightError(err instanceof Error ? err.message : 'Failed to generate insight');
|
| 182 |
+
} finally {
|
| 183 |
+
setInsightLoading(false);
|
| 184 |
+
}
|
| 185 |
+
}, [authToken, apiBaseUrl]);
|
| 186 |
+
|
| 187 |
+
const syncProfileToGraph = useCallback(async () => {
|
| 188 |
+
if (!authToken) {
|
| 189 |
+
setError('Authentication required');
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
setSyncing(true);
|
| 194 |
+
setError(null);
|
| 195 |
+
setSyncNotice(null);
|
| 196 |
+
|
| 197 |
+
try {
|
| 198 |
+
const response = await fetch(`${apiBaseUrl}/api/profile/sync-to-memory`, {
|
| 199 |
+
method: 'POST',
|
| 200 |
+
headers: {
|
| 201 |
+
Authorization: `Bearer ${authToken}`,
|
| 202 |
+
'Content-Type': 'application/json',
|
| 203 |
+
},
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
if (!response.ok) {
|
| 207 |
+
throw new Error(`Profile sync failed: ${response.status}`);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
const payload = await response.json().catch(() => null);
|
| 211 |
+
if (payload?.success === false) {
|
| 212 |
+
throw new Error(payload?.message || 'Profile sync failed');
|
| 213 |
+
}
|
| 214 |
+
const syncedFacts = payload?.synced?.facts_synced;
|
| 215 |
+
if (typeof syncedFacts === 'number') {
|
| 216 |
+
setSyncNotice(`Synced ${syncedFacts} facts from your profile. Refreshing graph...`);
|
| 217 |
+
} else {
|
| 218 |
+
setSyncNotice('Profile sync completed. Refreshing graph...');
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
await fetchGraphData();
|
| 222 |
+
} catch (err) {
|
| 223 |
+
setError(err instanceof Error ? err.message : 'Failed to sync profile data');
|
| 224 |
+
} finally {
|
| 225 |
+
setSyncing(false);
|
| 226 |
+
}
|
| 227 |
+
}, [authToken, apiBaseUrl, fetchGraphData]);
|
| 228 |
+
|
| 229 |
useEffect(() => {
|
| 230 |
if (!collapsed && authToken) {
|
| 231 |
fetchGraphData();
|
|
|
|
| 233 |
}, [collapsed, authToken, fetchGraphData]);
|
| 234 |
|
| 235 |
// Simple force-directed layout calculation (simplified)
|
| 236 |
+
const calculateLayout = useCallback((nodes: GraphNode[]) => {
|
| 237 |
const width = 600;
|
| 238 |
const height = 400;
|
| 239 |
const centerX = width / 2;
|
|
|
|
| 277 |
return nodePositions;
|
| 278 |
}, []);
|
| 279 |
|
| 280 |
+
const nodePositions = useMemo(
|
| 281 |
+
() => (graphData ? calculateLayout(graphData.nodes) : {}),
|
| 282 |
+
[graphData, calculateLayout]
|
| 283 |
+
);
|
| 284 |
|
| 285 |
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.2, 2));
|
| 286 |
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.2, 0.5));
|
|
|
|
| 334 |
) : !graphData || graphData.nodes.length === 0 ? (
|
| 335 |
<div className="health-graph-empty">
|
| 336 |
<Sparkles size={32} />
|
| 337 |
+
<p>No graph data for your account yet</p>
|
| 338 |
+
<span>
|
| 339 |
+
This graph is user-scoped. Sync your profile data or upload reports to generate
|
| 340 |
+
relationships.
|
| 341 |
+
</span>
|
| 342 |
+
{syncNotice && (
|
| 343 |
+
<span className="health-graph-empty-note">{syncNotice}</span>
|
| 344 |
+
)}
|
| 345 |
+
<button
|
| 346 |
+
className="health-graph-empty-sync"
|
| 347 |
+
onClick={syncProfileToGraph}
|
| 348 |
+
disabled={syncing || loading}
|
| 349 |
+
>
|
| 350 |
+
{syncing ? <RefreshCw size={14} className="spinning" /> : <Sparkles size={14} />}
|
| 351 |
+
<span>{syncing ? 'Syncing...' : 'Sync Profile Data'}</span>
|
| 352 |
+
</button>
|
| 353 |
</div>
|
| 354 |
) : (
|
| 355 |
<>
|
|
|
|
| 366 |
<Maximize2 size={16} />
|
| 367 |
</button>
|
| 368 |
</div>
|
| 369 |
+
<div className="health-graph-actions">
|
| 370 |
+
<button
|
| 371 |
+
className="health-graph-sync"
|
| 372 |
+
onClick={syncProfileToGraph}
|
| 373 |
+
disabled={syncing || loading}
|
| 374 |
+
title="Sync profile into per-user graph"
|
| 375 |
+
>
|
| 376 |
+
{syncing ? <RefreshCw size={14} className="spinning" /> : <Sparkles size={14} />}
|
| 377 |
+
<span>{syncing ? 'Syncing...' : 'Sync Profile'}</span>
|
| 378 |
+
</button>
|
| 379 |
+
<button
|
| 380 |
+
className="health-graph-refresh"
|
| 381 |
+
onClick={fetchGraphData}
|
| 382 |
+
title="Refresh"
|
| 383 |
+
>
|
| 384 |
+
<RefreshCw size={16} />
|
| 385 |
+
</button>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
{syncNotice && (
|
| 390 |
+
<div className="health-graph-note">
|
| 391 |
+
<Info size={14} />
|
| 392 |
+
<span>{syncNotice}</span>
|
| 393 |
+
</div>
|
| 394 |
+
)}
|
| 395 |
+
|
| 396 |
+
{/* AI Insights Panel */}
|
| 397 |
+
<div className="health-graph-insights">
|
| 398 |
+
<div className="insights-header">
|
| 399 |
+
<Sparkles size={16} className="insights-icon" />
|
| 400 |
+
<span className="insights-title">AI-Powered Insights</span>
|
| 401 |
+
</div>
|
| 402 |
+
<div className="insights-buttons">
|
| 403 |
+
{INSIGHT_BUTTONS.map(({ type, icon: Icon, label, description }) => (
|
| 404 |
+
<button
|
| 405 |
+
key={type}
|
| 406 |
+
className={`insight-button insight-button-${type} ${activeInsightType === type ? 'active' : ''}`}
|
| 407 |
+
onClick={() => generateInsight(type)}
|
| 408 |
+
disabled={insightLoading}
|
| 409 |
+
title={description}
|
| 410 |
+
>
|
| 411 |
+
{insightLoading && activeInsightType === type ? (
|
| 412 |
+
<Loader2 size={16} className="spinning" />
|
| 413 |
+
) : (
|
| 414 |
+
<Icon size={16} />
|
| 415 |
+
)}
|
| 416 |
+
<span>{label}</span>
|
| 417 |
+
</button>
|
| 418 |
+
))}
|
| 419 |
+
</div>
|
| 420 |
+
|
| 421 |
+
<AnimatePresence>
|
| 422 |
+
{(insightContent || insightLoading || insightError) && (
|
| 423 |
+
<motion.div
|
| 424 |
+
initial={{ height: 0, opacity: 0 }}
|
| 425 |
+
animate={{ height: 'auto', opacity: 1 }}
|
| 426 |
+
exit={{ height: 0, opacity: 0 }}
|
| 427 |
+
transition={{ duration: 0.2 }}
|
| 428 |
+
className="insights-content"
|
| 429 |
+
>
|
| 430 |
+
{insightLoading ? (
|
| 431 |
+
<div className="insights-loading">
|
| 432 |
+
<Loader2 size={20} className="spinning" />
|
| 433 |
+
<span>Analyzing your health data...</span>
|
| 434 |
+
</div>
|
| 435 |
+
) : insightError ? (
|
| 436 |
+
<div className="insights-error">
|
| 437 |
+
<AlertCircle size={16} />
|
| 438 |
+
<span>{insightError}</span>
|
| 439 |
+
</div>
|
| 440 |
+
) : insightContent && (
|
| 441 |
+
<>
|
| 442 |
+
<div className="insights-text">
|
| 443 |
+
{insightContent.split('\n').filter(line => line.trim()).map((line, i) => (
|
| 444 |
+
<p key={i}>{line}</p>
|
| 445 |
+
))}
|
| 446 |
+
</div>
|
| 447 |
+
{insightSources.length > 0 && (
|
| 448 |
+
<details className="insights-sources">
|
| 449 |
+
<summary>
|
| 450 |
+
<Info size={12} />
|
| 451 |
+
<span>{insightSources.length} facts used</span>
|
| 452 |
+
</summary>
|
| 453 |
+
<ul>
|
| 454 |
+
{insightSources.map((source, i) => (
|
| 455 |
+
<li key={i}>{source}</li>
|
| 456 |
+
))}
|
| 457 |
+
</ul>
|
| 458 |
+
</details>
|
| 459 |
+
)}
|
| 460 |
+
</>
|
| 461 |
+
)}
|
| 462 |
+
</motion.div>
|
| 463 |
+
)}
|
| 464 |
+
</AnimatePresence>
|
| 465 |
</div>
|
| 466 |
|
| 467 |
{error && (
|
|
|
|
| 481 |
{/* Relationship lines */}
|
| 482 |
<g className="relationships">
|
| 483 |
{graphData.relationships.map((rel, index) => {
|
| 484 |
+
const sourcePos = nodePositions[normalizeNodeId(rel.source)];
|
| 485 |
+
const targetPos = nodePositions[normalizeNodeId(rel.target)];
|
| 486 |
|
| 487 |
if (!sourcePos || !targetPos) return null;
|
| 488 |
|
|
@@ -8,7 +8,6 @@ import { useEffect } from 'react';
|
|
| 8 |
import { useNavigate } from 'react-router-dom';
|
| 9 |
import { motion } from 'framer-motion';
|
| 10 |
import { Check, ArrowRight } from 'lucide-react';
|
| 11 |
-
import { API_BASE_URL } from '../config/api';
|
| 12 |
import './ProfileSuccessScreen.css';
|
| 13 |
|
| 14 |
interface ProfileSuccessScreenProps {
|
|
@@ -23,41 +22,19 @@ export default function ProfileSuccessScreen({
|
|
| 23 |
const navigate = useNavigate();
|
| 24 |
|
| 25 |
useEffect(() => {
|
| 26 |
-
//
|
| 27 |
-
|
| 28 |
-
const authToken = localStorage.getItem('authToken');
|
| 29 |
-
if (!authToken) return;
|
| 30 |
-
|
| 31 |
-
try {
|
| 32 |
-
await fetch(`${API_BASE_URL}/api/profile/sync-to-memory`, {
|
| 33 |
-
method: 'POST',
|
| 34 |
-
headers: {
|
| 35 |
-
'Authorization': `Bearer ${authToken}`,
|
| 36 |
-
'Content-Type': 'application/json',
|
| 37 |
-
},
|
| 38 |
-
});
|
| 39 |
-
console.log('Profile synced to memory/graph layers');
|
| 40 |
-
} catch (error) {
|
| 41 |
-
console.warn('Failed to sync profile to memory:', error);
|
| 42 |
-
// Non-blocking - don't prevent navigation on failure
|
| 43 |
-
}
|
| 44 |
-
};
|
| 45 |
-
|
| 46 |
-
syncToMemory();
|
| 47 |
-
}, []);
|
| 48 |
-
|
| 49 |
-
useEffect(() => {
|
| 50 |
if (autoRedirect) {
|
| 51 |
const timer = setTimeout(() => {
|
| 52 |
-
navigate('/
|
| 53 |
}, redirectDelay);
|
| 54 |
|
| 55 |
return () => clearTimeout(timer);
|
| 56 |
}
|
| 57 |
}, [autoRedirect, redirectDelay, navigate]);
|
| 58 |
|
| 59 |
-
const
|
| 60 |
-
navigate('/
|
| 61 |
};
|
| 62 |
|
| 63 |
const handleSettings = () => {
|
|
@@ -140,9 +117,9 @@ export default function ProfileSuccessScreen({
|
|
| 140 |
>
|
| 141 |
<button
|
| 142 |
className="success-btn success-btn-primary"
|
| 143 |
-
onClick={
|
| 144 |
>
|
| 145 |
-
|
| 146 |
<ArrowRight size={18} />
|
| 147 |
</button>
|
| 148 |
<button
|
|
@@ -161,7 +138,7 @@ export default function ProfileSuccessScreen({
|
|
| 161 |
animate={{ opacity: 1 }}
|
| 162 |
transition={{ delay: 1, duration: 0.3 }}
|
| 163 |
>
|
| 164 |
-
Redirecting to
|
| 165 |
</motion.p>
|
| 166 |
)}
|
| 167 |
</motion.div>
|
|
|
|
| 8 |
import { useNavigate } from 'react-router-dom';
|
| 9 |
import { motion } from 'framer-motion';
|
| 10 |
import { Check, ArrowRight } from 'lucide-react';
|
|
|
|
| 11 |
import './ProfileSuccessScreen.css';
|
| 12 |
|
| 13 |
interface ProfileSuccessScreenProps {
|
|
|
|
| 22 |
const navigate = useNavigate();
|
| 23 |
|
| 24 |
useEffect(() => {
|
| 25 |
+
// Profile sync is now handled by the Features page which shows a
|
| 26 |
+
// live sync overlay. We just redirect there after the success animation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
if (autoRedirect) {
|
| 28 |
const timer = setTimeout(() => {
|
| 29 |
+
navigate('/features', { state: { fromProfileSync: true } });
|
| 30 |
}, redirectDelay);
|
| 31 |
|
| 32 |
return () => clearTimeout(timer);
|
| 33 |
}
|
| 34 |
}, [autoRedirect, redirectDelay, navigate]);
|
| 35 |
|
| 36 |
+
const handleFeatures = () => {
|
| 37 |
+
navigate('/features', { state: { fromProfileSync: true } });
|
| 38 |
};
|
| 39 |
|
| 40 |
const handleSettings = () => {
|
|
|
|
| 117 |
>
|
| 118 |
<button
|
| 119 |
className="success-btn success-btn-primary"
|
| 120 |
+
onClick={handleFeatures}
|
| 121 |
>
|
| 122 |
+
View Health Intelligence
|
| 123 |
<ArrowRight size={18} />
|
| 124 |
</button>
|
| 125 |
<button
|
|
|
|
| 138 |
animate={{ opacity: 1 }}
|
| 139 |
transition={{ delay: 1, duration: 0.3 }}
|
| 140 |
>
|
| 141 |
+
Redirecting to Health Intelligence...
|
| 142 |
</motion.p>
|
| 143 |
)}
|
| 144 |
</motion.div>
|
|
@@ -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, Bell, FileText, Activity, Sparkles, Pill, PhoneCall } from 'lucide-react';
|
| 5 |
import { logout } from '../../utils/auth';
|
| 6 |
import Logo from '../ui/Logo';
|
| 7 |
import './DashboardNavbar.css';
|
|
@@ -23,6 +23,7 @@ function DashboardNavbar({ userName = 'User', userStatus = '87% Healthy' }: Dash
|
|
| 23 |
{ label: 'Voice Agent', path: '/voice-agent', icon: PhoneCall },
|
| 24 |
{ label: 'AI Summary', path: '/report-summary', icon: Sparkles },
|
| 25 |
{ label: 'Recommendations', path: '/recommendations', icon: Activity },
|
|
|
|
| 26 |
{ label: 'Medicines', path: '/medicines', icon: Pill },
|
| 27 |
];
|
| 28 |
|
|
@@ -120,6 +121,14 @@ function DashboardNavbar({ userName = 'User', userStatus = '87% Healthy' }: Dash
|
|
| 120 |
<Settings size={16} />
|
| 121 |
Settings
|
| 122 |
</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
<div className="dashboard-user-dropdown-divider" />
|
| 124 |
<button
|
| 125 |
className="dashboard-user-dropdown-item"
|
|
|
|
| 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, Bell, 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';
|
|
|
|
| 23 |
{ label: 'Voice Agent', path: '/voice-agent', icon: PhoneCall },
|
| 24 |
{ label: 'AI Summary', path: '/report-summary', icon: Sparkles },
|
| 25 |
{ label: 'Recommendations', path: '/recommendations', icon: Activity },
|
| 26 |
+
{ label: 'Features', path: '/features', icon: Network },
|
| 27 |
{ label: 'Medicines', path: '/medicines', icon: Pill },
|
| 28 |
];
|
| 29 |
|
|
|
|
| 121 |
<Settings size={16} />
|
| 122 |
Settings
|
| 123 |
</Link>
|
| 124 |
+
<Link
|
| 125 |
+
to="/features"
|
| 126 |
+
className="dashboard-user-dropdown-item"
|
| 127 |
+
onClick={() => setIsUserMenuOpen(false)}
|
| 128 |
+
>
|
| 129 |
+
<Network size={16} />
|
| 130 |
+
Features
|
| 131 |
+
</Link>
|
| 132 |
<div className="dashboard-user-dropdown-divider" />
|
| 133 |
<button
|
| 134 |
className="dashboard-user-dropdown-item"
|
|
@@ -423,4 +423,138 @@
|
|
| 423 |
flex-direction: column;
|
| 424 |
align-items: flex-start;
|
| 425 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
}
|
|
|
|
| 423 |
flex-direction: column;
|
| 424 |
align-items: flex-start;
|
| 425 |
}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/* ===================================================================
|
| 429 |
+
Sync Overlay – shown when arriving from profile questionnaire
|
| 430 |
+
=================================================================== */
|
| 431 |
+
|
| 432 |
+
.sync-overlay {
|
| 433 |
+
position: fixed;
|
| 434 |
+
inset: 0;
|
| 435 |
+
z-index: 1000;
|
| 436 |
+
display: flex;
|
| 437 |
+
align-items: center;
|
| 438 |
+
justify-content: center;
|
| 439 |
+
background: rgba(18, 20, 22, 0.82);
|
| 440 |
+
backdrop-filter: blur(12px);
|
| 441 |
+
-webkit-backdrop-filter: blur(12px);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.sync-overlay-card {
|
| 445 |
+
display: flex;
|
| 446 |
+
flex-direction: column;
|
| 447 |
+
align-items: center;
|
| 448 |
+
text-align: center;
|
| 449 |
+
gap: 20px;
|
| 450 |
+
padding: 48px 40px 40px;
|
| 451 |
+
background: var(--dash-card-bg);
|
| 452 |
+
border: 1px solid var(--dash-border);
|
| 453 |
+
border-radius: 24px;
|
| 454 |
+
max-width: 420px;
|
| 455 |
+
width: 90%;
|
| 456 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.sync-overlay-icon {
|
| 460 |
+
width: 64px;
|
| 461 |
+
height: 64px;
|
| 462 |
+
border-radius: 50%;
|
| 463 |
+
display: flex;
|
| 464 |
+
align-items: center;
|
| 465 |
+
justify-content: center;
|
| 466 |
+
background: rgba(106, 145, 117, 0.15);
|
| 467 |
+
color: var(--dash-accent);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.sync-overlay-title {
|
| 471 |
+
margin: 0;
|
| 472 |
+
font-size: 1.35rem;
|
| 473 |
+
font-weight: 700;
|
| 474 |
+
color: var(--dash-text);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/* Sync steps */
|
| 478 |
+
.sync-steps {
|
| 479 |
+
display: flex;
|
| 480 |
+
flex-direction: column;
|
| 481 |
+
gap: 12px;
|
| 482 |
+
width: 100%;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.sync-step {
|
| 486 |
+
display: flex;
|
| 487 |
+
align-items: center;
|
| 488 |
+
gap: 12px;
|
| 489 |
+
padding: 10px 16px;
|
| 490 |
+
border-radius: 12px;
|
| 491 |
+
background: rgba(255, 255, 255, 0.03);
|
| 492 |
+
border: 1px solid transparent;
|
| 493 |
+
transition: all 0.25s ease;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.sync-step.active {
|
| 497 |
+
background: rgba(106, 145, 117, 0.08);
|
| 498 |
+
border-color: rgba(106, 145, 117, 0.25);
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.sync-step.done {
|
| 502 |
+
opacity: 0.7;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.sync-step-icon {
|
| 506 |
+
width: 28px;
|
| 507 |
+
height: 28px;
|
| 508 |
+
border-radius: 8px;
|
| 509 |
+
display: flex;
|
| 510 |
+
align-items: center;
|
| 511 |
+
justify-content: center;
|
| 512 |
+
flex-shrink: 0;
|
| 513 |
+
background: rgba(106, 145, 117, 0.12);
|
| 514 |
+
color: var(--dash-text-muted);
|
| 515 |
+
transition: all 0.25s ease;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.sync-step.active .sync-step-icon {
|
| 519 |
+
background: rgba(106, 145, 117, 0.2);
|
| 520 |
+
color: var(--dash-accent);
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
.sync-step.done .sync-step-icon {
|
| 524 |
+
background: rgba(106, 145, 117, 0.2);
|
| 525 |
+
color: var(--dash-accent);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
.sync-step-label {
|
| 529 |
+
flex: 1;
|
| 530 |
+
font-size: 14px;
|
| 531 |
+
font-weight: 500;
|
| 532 |
+
color: var(--dash-text-muted);
|
| 533 |
+
text-align: left;
|
| 534 |
+
transition: color 0.25s ease;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.sync-step.active .sync-step-label {
|
| 538 |
+
color: var(--dash-text);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.sync-step-spinner {
|
| 542 |
+
color: var(--dash-accent);
|
| 543 |
+
display: flex;
|
| 544 |
+
align-items: center;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
/* Result summaries */
|
| 548 |
+
.sync-overlay-summary {
|
| 549 |
+
margin: 0;
|
| 550 |
+
font-size: 14px;
|
| 551 |
+
color: var(--dash-accent);
|
| 552 |
+
font-weight: 500;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.sync-overlay-error {
|
| 556 |
+
margin: 0;
|
| 557 |
+
font-size: 13px;
|
| 558 |
+
color: var(--dash-danger);
|
| 559 |
+
line-height: 1.5;
|
| 560 |
}
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import { useState, useEffect } from 'react';
|
| 2 |
-
import { motion } from 'framer-motion';
|
| 3 |
import {
|
| 4 |
Brain,
|
| 5 |
Network,
|
|
@@ -9,9 +9,12 @@ import {
|
|
| 9 |
Database,
|
| 10 |
Cpu,
|
| 11 |
Zap,
|
| 12 |
-
Link2
|
|
|
|
|
|
|
|
|
|
| 13 |
} from 'lucide-react';
|
| 14 |
-
import { useNavigate } from 'react-router-dom';
|
| 15 |
import DashboardNavbar from '../components/dashboard/DashboardNavbar';
|
| 16 |
import MemoryDashboard from '../components/MemoryDashboard';
|
| 17 |
import HealthGraph from '../components/HealthGraph';
|
|
@@ -22,10 +25,86 @@ import './Features.css';
|
|
| 22 |
|
| 23 |
function Features() {
|
| 24 |
const navigate = useNavigate();
|
|
|
|
| 25 |
const [authToken, setAuthToken] = useState<string | null>(null);
|
| 26 |
const [userName, setUserName] = useState('User');
|
| 27 |
const [loading, setLoading] = useState(true);
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
useEffect(() => {
|
| 30 |
const token = localStorage.getItem('access_token');
|
| 31 |
if (!token) {
|
|
@@ -44,7 +123,12 @@ function Features() {
|
|
| 44 |
})
|
| 45 |
.catch(console.error)
|
| 46 |
.finally(() => setLoading(false));
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
if (loading) {
|
| 50 |
return (
|
|
@@ -69,6 +153,100 @@ function Features() {
|
|
| 69 |
|
| 70 |
<DashboardNavbar userName={userName} userStatus="" />
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
<div className="features-content">
|
| 73 |
<div className="features-container">
|
| 74 |
|
|
@@ -197,6 +375,7 @@ function Features() {
|
|
| 197 |
|
| 198 |
<div className="section-content">
|
| 199 |
<MemoryDashboard
|
|
|
|
| 200 |
authToken={authToken || undefined}
|
| 201 |
apiBaseUrl={API_BASE_URL}
|
| 202 |
defaultCollapsed={false}
|
|
@@ -228,6 +407,7 @@ function Features() {
|
|
| 228 |
|
| 229 |
<div className="section-content section-content-large">
|
| 230 |
<HealthGraph
|
|
|
|
| 231 |
authToken={authToken || undefined}
|
| 232 |
apiBaseUrl={API_BASE_URL}
|
| 233 |
defaultCollapsed={false}
|
|
@@ -259,4 +439,35 @@ function Features() {
|
|
| 259 |
);
|
| 260 |
}
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
export default Features;
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import {
|
| 4 |
Brain,
|
| 5 |
Network,
|
|
|
|
| 9 |
Database,
|
| 10 |
Cpu,
|
| 11 |
Zap,
|
| 12 |
+
Link2,
|
| 13 |
+
Check,
|
| 14 |
+
RefreshCw,
|
| 15 |
+
AlertCircle,
|
| 16 |
} from 'lucide-react';
|
| 17 |
+
import { useNavigate, useLocation } from 'react-router-dom';
|
| 18 |
import DashboardNavbar from '../components/dashboard/DashboardNavbar';
|
| 19 |
import MemoryDashboard from '../components/MemoryDashboard';
|
| 20 |
import HealthGraph from '../components/HealthGraph';
|
|
|
|
| 25 |
|
| 26 |
function Features() {
|
| 27 |
const navigate = useNavigate();
|
| 28 |
+
const location = useLocation();
|
| 29 |
const [authToken, setAuthToken] = useState<string | null>(null);
|
| 30 |
const [userName, setUserName] = useState('User');
|
| 31 |
const [loading, setLoading] = useState(true);
|
| 32 |
|
| 33 |
+
// Sync overlay state – activated when arriving from profile completion
|
| 34 |
+
const fromProfileSync = !!(location.state as { fromProfileSync?: boolean })?.fromProfileSync;
|
| 35 |
+
const [syncOverlayVisible, setSyncOverlayVisible] = useState(fromProfileSync);
|
| 36 |
+
const [syncPhase, setSyncPhase] = useState<'preparing' | 'memory' | 'graph' | 'done' | 'error'>('preparing');
|
| 37 |
+
const [syncResult, setSyncResult] = useState<{
|
| 38 |
+
factsSynced: number;
|
| 39 |
+
memoryOk: boolean;
|
| 40 |
+
graphOk: boolean;
|
| 41 |
+
errors: string[];
|
| 42 |
+
} | null>(null);
|
| 43 |
+
// Key used to force-remount child components after sync completes
|
| 44 |
+
const [syncKey, setSyncKey] = useState(0);
|
| 45 |
+
|
| 46 |
+
// Clears router state so a page refresh doesn't re-trigger the overlay
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (fromProfileSync) {
|
| 49 |
+
window.history.replaceState({}, document.title);
|
| 50 |
+
}
|
| 51 |
+
}, [fromProfileSync]);
|
| 52 |
+
|
| 53 |
+
// ---- Profile→Memory/Graph sync logic ----
|
| 54 |
+
const runProfileSync = useCallback(async (token: string) => {
|
| 55 |
+
setSyncPhase('preparing');
|
| 56 |
+
// Small visual delay so the user sees the "preparing" step
|
| 57 |
+
await new Promise(r => setTimeout(r, 800));
|
| 58 |
+
|
| 59 |
+
setSyncPhase('memory');
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
const response = await fetch(`${API_BASE_URL}/api/profile/sync-to-memory`, {
|
| 63 |
+
method: 'POST',
|
| 64 |
+
headers: {
|
| 65 |
+
Authorization: `Bearer ${token}`,
|
| 66 |
+
'Content-Type': 'application/json',
|
| 67 |
+
},
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
const payload = await response.json().catch(() => null);
|
| 71 |
+
|
| 72 |
+
if (!response.ok || payload?.success === false) {
|
| 73 |
+
throw new Error(payload?.message || `Sync failed (${response.status})`);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const synced = payload?.synced ?? {};
|
| 77 |
+
// Transition through the "graph" visual phase
|
| 78 |
+
setSyncPhase('graph');
|
| 79 |
+
await new Promise(r => setTimeout(r, 600));
|
| 80 |
+
|
| 81 |
+
setSyncResult({
|
| 82 |
+
factsSynced: synced.facts_synced ?? 0,
|
| 83 |
+
memoryOk: !!synced.memory,
|
| 84 |
+
graphOk: !!synced.graph,
|
| 85 |
+
errors: payload?.errors ?? [],
|
| 86 |
+
});
|
| 87 |
+
setSyncPhase('done');
|
| 88 |
+
|
| 89 |
+
// Force child re-mount so they fetch fresh data
|
| 90 |
+
setSyncKey(prev => prev + 1);
|
| 91 |
+
|
| 92 |
+
// Auto-dismiss overlay after a brief pause
|
| 93 |
+
setTimeout(() => setSyncOverlayVisible(false), 2200);
|
| 94 |
+
} catch (err) {
|
| 95 |
+
console.error('Profile sync error:', err);
|
| 96 |
+
setSyncResult({
|
| 97 |
+
factsSynced: 0,
|
| 98 |
+
memoryOk: false,
|
| 99 |
+
graphOk: false,
|
| 100 |
+
errors: [err instanceof Error ? err.message : 'Unknown error'],
|
| 101 |
+
});
|
| 102 |
+
setSyncPhase('error');
|
| 103 |
+
// Auto-dismiss error overlay after longer pause
|
| 104 |
+
setTimeout(() => setSyncOverlayVisible(false), 3500);
|
| 105 |
+
}
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
useEffect(() => {
|
| 109 |
const token = localStorage.getItem('access_token');
|
| 110 |
if (!token) {
|
|
|
|
| 123 |
})
|
| 124 |
.catch(console.error)
|
| 125 |
.finally(() => setLoading(false));
|
| 126 |
+
|
| 127 |
+
// If arriving from profile completion, trigger the sync
|
| 128 |
+
if (fromProfileSync) {
|
| 129 |
+
runProfileSync(token);
|
| 130 |
+
}
|
| 131 |
+
}, [navigate, fromProfileSync, runProfileSync]);
|
| 132 |
|
| 133 |
if (loading) {
|
| 134 |
return (
|
|
|
|
| 153 |
|
| 154 |
<DashboardNavbar userName={userName} userStatus="" />
|
| 155 |
|
| 156 |
+
{/* Profile Sync Overlay */}
|
| 157 |
+
<AnimatePresence>
|
| 158 |
+
{syncOverlayVisible && (
|
| 159 |
+
<motion.div
|
| 160 |
+
className="sync-overlay"
|
| 161 |
+
initial={{ opacity: 0 }}
|
| 162 |
+
animate={{ opacity: 1 }}
|
| 163 |
+
exit={{ opacity: 0 }}
|
| 164 |
+
transition={{ duration: 0.35 }}
|
| 165 |
+
>
|
| 166 |
+
<motion.div
|
| 167 |
+
className="sync-overlay-card"
|
| 168 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 169 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 170 |
+
exit={{ scale: 0.95, opacity: 0 }}
|
| 171 |
+
transition={{ type: 'spring', stiffness: 200, damping: 22 }}
|
| 172 |
+
>
|
| 173 |
+
{/* Animated header icon */}
|
| 174 |
+
<motion.div
|
| 175 |
+
className="sync-overlay-icon"
|
| 176 |
+
animate={syncPhase === 'done' ? { scale: [1, 1.15, 1] } : { rotate: 360 }}
|
| 177 |
+
transition={
|
| 178 |
+
syncPhase === 'done'
|
| 179 |
+
? { duration: 0.4 }
|
| 180 |
+
: { repeat: Infinity, duration: 1.8, ease: 'linear' }
|
| 181 |
+
}
|
| 182 |
+
>
|
| 183 |
+
{syncPhase === 'done' ? (
|
| 184 |
+
<Check size={32} />
|
| 185 |
+
) : syncPhase === 'error' ? (
|
| 186 |
+
<AlertCircle size={32} />
|
| 187 |
+
) : (
|
| 188 |
+
<RefreshCw size={32} />
|
| 189 |
+
)}
|
| 190 |
+
</motion.div>
|
| 191 |
+
|
| 192 |
+
<h2 className="sync-overlay-title">
|
| 193 |
+
{syncPhase === 'done'
|
| 194 |
+
? 'All Set!'
|
| 195 |
+
: syncPhase === 'error'
|
| 196 |
+
? 'Sync Issue'
|
| 197 |
+
: 'Syncing Your Health Data'}
|
| 198 |
+
</h2>
|
| 199 |
+
|
| 200 |
+
{/* Step indicators */}
|
| 201 |
+
<div className="sync-steps">
|
| 202 |
+
<SyncStep
|
| 203 |
+
label="Analysing profile"
|
| 204 |
+
active={syncPhase === 'preparing'}
|
| 205 |
+
done={syncPhase !== 'preparing'}
|
| 206 |
+
icon={<Database size={16} />}
|
| 207 |
+
/>
|
| 208 |
+
<SyncStep
|
| 209 |
+
label="Syncing to Health Memory"
|
| 210 |
+
active={syncPhase === 'memory'}
|
| 211 |
+
done={['graph', 'done'].includes(syncPhase)}
|
| 212 |
+
icon={<Brain size={16} />}
|
| 213 |
+
/>
|
| 214 |
+
<SyncStep
|
| 215 |
+
label="Building Knowledge Graph"
|
| 216 |
+
active={syncPhase === 'graph'}
|
| 217 |
+
done={syncPhase === 'done'}
|
| 218 |
+
icon={<Network size={16} />}
|
| 219 |
+
/>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
{/* Result summary */}
|
| 223 |
+
{syncPhase === 'done' && syncResult && (
|
| 224 |
+
<motion.p
|
| 225 |
+
className="sync-overlay-summary"
|
| 226 |
+
initial={{ opacity: 0, y: 8 }}
|
| 227 |
+
animate={{ opacity: 1, y: 0 }}
|
| 228 |
+
>
|
| 229 |
+
{syncResult.factsSynced} facts synced
|
| 230 |
+
{syncResult.memoryOk && ' to Memory'}
|
| 231 |
+
{syncResult.memoryOk && syncResult.graphOk && ' &'}
|
| 232 |
+
{syncResult.graphOk && ' Knowledge Graph'}
|
| 233 |
+
</motion.p>
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
+
{syncPhase === 'error' && syncResult && (
|
| 237 |
+
<motion.p
|
| 238 |
+
className="sync-overlay-error"
|
| 239 |
+
initial={{ opacity: 0, y: 8 }}
|
| 240 |
+
animate={{ opacity: 1, y: 0 }}
|
| 241 |
+
>
|
| 242 |
+
{syncResult.errors[0] || 'Something went wrong. Your data is safe — sync will retry later.'}
|
| 243 |
+
</motion.p>
|
| 244 |
+
)}
|
| 245 |
+
</motion.div>
|
| 246 |
+
</motion.div>
|
| 247 |
+
)}
|
| 248 |
+
</AnimatePresence>
|
| 249 |
+
|
| 250 |
<div className="features-content">
|
| 251 |
<div className="features-container">
|
| 252 |
|
|
|
|
| 375 |
|
| 376 |
<div className="section-content">
|
| 377 |
<MemoryDashboard
|
| 378 |
+
key={`mem-${syncKey}`}
|
| 379 |
authToken={authToken || undefined}
|
| 380 |
apiBaseUrl={API_BASE_URL}
|
| 381 |
defaultCollapsed={false}
|
|
|
|
| 407 |
|
| 408 |
<div className="section-content section-content-large">
|
| 409 |
<HealthGraph
|
| 410 |
+
key={`graph-${syncKey}`}
|
| 411 |
authToken={authToken || undefined}
|
| 412 |
apiBaseUrl={API_BASE_URL}
|
| 413 |
defaultCollapsed={false}
|
|
|
|
| 439 |
);
|
| 440 |
}
|
| 441 |
|
| 442 |
+
/* ------------------------------------------------------------------ */
|
| 443 |
+
/* Sync step indicator sub-component */
|
| 444 |
+
/* ------------------------------------------------------------------ */
|
| 445 |
+
|
| 446 |
+
interface SyncStepProps {
|
| 447 |
+
label: string;
|
| 448 |
+
active: boolean;
|
| 449 |
+
done: boolean;
|
| 450 |
+
icon: React.ReactNode;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
function SyncStep({ label, active, done, icon }: SyncStepProps) {
|
| 454 |
+
return (
|
| 455 |
+
<div className={`sync-step ${active ? 'active' : ''} ${done ? 'done' : ''}`}>
|
| 456 |
+
<span className="sync-step-icon">
|
| 457 |
+
{done ? <Check size={16} /> : icon}
|
| 458 |
+
</span>
|
| 459 |
+
<span className="sync-step-label">{label}</span>
|
| 460 |
+
{active && (
|
| 461 |
+
<motion.span
|
| 462 |
+
className="sync-step-spinner"
|
| 463 |
+
animate={{ rotate: 360 }}
|
| 464 |
+
transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}
|
| 465 |
+
>
|
| 466 |
+
<RefreshCw size={12} />
|
| 467 |
+
</motion.span>
|
| 468 |
+
)}
|
| 469 |
+
</div>
|
| 470 |
+
);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
export default Features;
|