Hetansh Waghela commited on
Commit
d6e2b5a
·
1 Parent(s): b812380

feat: OpenRouter as primary LLM with Mem0/Graphiti intelligence layer

Browse files

MAJOR 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 CHANGED
@@ -43,21 +43,33 @@ FRONTEND_ORIGIN=http://localhost:5173
43
  # =============================================================================
44
  # SECTION 3: AI/LLM PROVIDERS [AT LEAST ONE REQUIRED]
45
  # =============================================================================
46
- # Choose at least ONE provider for AI responses:
47
- # Primary: GROK_API_KEY (recommended - most medical-focused)
48
- # Fallback: GEMINI_API_KEY (free tier available)
49
- # Alternative: OPENAI_API_KEY (premium)
50
- # Local: OLLAMA_BASE_URL (self-hosted)
51
-
52
- # ---- GROK API (xAI) - RECOMMENDED ----
53
- # Get from: https://console.x.ai/
54
- # Free tier: 1M tokens/month available
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Enable as fallback if Grok not available
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) ----
backend/.env.example CHANGED
@@ -39,22 +39,30 @@ FRONTEND_ORIGIN=http://localhost:5173
39
  # =============================================================================
40
  # SECTION 2: AI/LLM PROVIDERS [ONE REQUIRED]
41
  # =============================================================================
42
- # Choose at least ONE LLM provider:
43
- # - Grok API (xAI) - RECOMMENDED, most medical-focused
44
- # - Google Gemini - FREE tier available, good fallback
45
- # - OpenAI - Premium option
46
- # - Ollama - Local/self-hosted option
47
-
48
- # ---- GROK API (xAI) - PRIMARY [RECOMMENDED] ----
49
- # Get from: https://console.x.ai/
50
- # Free tier available with 1M tokens/month
 
 
 
 
 
 
 
 
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
- # Supported free models: gemini-flash-latest, gemini-1.5-flash-8b
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
  # =============================================================================
backend/alembic/versions/8f1b6c345678_add_profile_completion_columns.py CHANGED
@@ -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=True, default=False))
24
  op.add_column('user_profiles', sa.Column('completed_at', sa.DateTime(), nullable=True))
25
-
26
- # Set default value for existing rows
27
- op.execute("UPDATE user_profiles SET is_completed = false WHERE is_completed IS NULL")
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:
backend/app/core/audit.py CHANGED
@@ -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
- try:
117
- os.makedirs(self.log_dir, exist_ok=True)
118
- except Exception as e:
119
- logging.warning(f"Could not create audit log directory: {e}")
 
 
 
 
 
 
 
 
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"""
backend/app/core/rate_limit.py CHANGED
@@ -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), # 3 signups per minute per IP
114
- "password_reset": (3, 300), # 3 reset requests per 5 minutes
115
 
116
  # API endpoints - moderate limits
117
- "api_default": (100, 60), # 100 requests per minute
118
- "upload": (10, 60), # 10 uploads per minute
119
- "ai_summary": (20, 60), # 20 AI requests per minute
 
 
 
120
 
121
  # Sensitive data endpoints - stricter
122
- "phi_access": (50, 60), # 50 PHI accesses per minute
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
 
backend/app/routes/graph.py CHANGED
@@ -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.search(query=query, limit=limit)
 
 
 
 
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.search(
 
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.search(query=query, limit=limit)
 
 
 
 
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.lower().replace(" ", "_"),
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.lower().replace(" ", "_"),
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
+ )
backend/app/routes/memory.py CHANGED
@@ -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, HTTPException, Query
8
- from sqlalchemy.ext.asyncio import AsyncSession
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=True,
96
- message=None
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 "Failed to delete memory"
189
  )
190
 
191
  except Exception as e:
@@ -262,8 +274,19 @@ async def search_memories(
262
 
263
  # Handle different response formats
264
  facts = []
265
- if isinstance(results, list):
266
- for item in results:
 
 
 
 
 
 
 
 
 
 
 
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=True,
277
- message=None
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:
backend/app/routes/profile.py CHANGED
@@ -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
- synced = {"memory": False, "graph": False, "facts_synced": 0}
616
-
617
- # Build facts from profile data
618
- facts_to_sync = []
619
-
620
- # Basic profile info
621
- profile = profile_data.get("profile")
622
- if profile:
623
- if profile.height_cm:
624
- facts_to_sync.append(f"User height is {profile.height_cm}cm")
625
- if profile.weight_kg:
626
- facts_to_sync.append(f"User weight is {profile.weight_kg}kg")
627
- if profile.biological_sex:
628
- facts_to_sync.append(f"User's biological sex is {profile.biological_sex}")
629
- if profile.activity_level:
630
- facts_to_sync.append(f"User's activity level is {profile.activity_level}")
631
- if profile.sleep_hours:
632
- facts_to_sync.append(f"User sleeps about {profile.sleep_hours} hours per night")
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 and facts_to_sync:
660
- try:
661
- for fact in facts_to_sync:
662
- await memory_service.add(
663
- messages=fact,
664
- user_id=user_id,
665
- metadata={"source": "questionnaire_sync"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  )
667
- synced["memory"] = True
668
- synced["facts_synced"] = len(facts_to_sync)
669
- logger.info(f"Synced {len(facts_to_sync)} facts to Mem0 for user {user_id}")
670
- except Exception as e:
671
- logger.warning(f"Failed to sync to Mem0: {e}")
 
 
 
 
 
 
672
 
673
  # Sync to Graph (Neo4j/Graphiti)
674
  graph_service = get_graph_service()
675
- if graph_service.client is not None and facts_to_sync:
676
  try:
677
- # Graphiti/Neo4j usually expects episodes or structured data
678
- # For now, add as text-based facts
679
- for fact in facts_to_sync:
680
- await graph_service.add_fact(
681
- fact=fact,
682
- source="questionnaire"
683
- )
684
  synced["graph"] = True
685
- logger.info(f"Synced {len(facts_to_sync)} facts to Neo4j for user {user_id}")
 
686
  except Exception as e:
687
- logger.warning(f"Failed to sync to Neo4j (may not support add_fact): {e}")
688
-
 
 
 
 
 
 
689
  return {
690
- "success": True,
691
  "synced": synced,
692
- "message": f"Synced {synced['facts_synced']} profile facts to memory/graph layers"
 
 
 
 
 
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
  }
 
backend/app/routes/websocket.py CHANGED
@@ -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": "Generating response..."},
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
- # Get user context from RAG
 
 
143
  async with async_session() as db:
144
- context = await rag_service.get_user_context(
145
- user_id=uuid_module.UUID(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.search(message_content, limit=5)
153
 
154
- # Assemble context for LLM in a consistent, structured format
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
- if context:
 
 
181
  parts.append("\n--- MEDICAL DATA & REPORTS ---")
182
- parts.append(context)
183
 
184
  full_context = "\n".join(parts)
185
-
186
- # Stream response from LLM
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": [], # TODO: Extract citations from context
 
 
 
 
 
205
  "session_id": session_id
206
  },
207
  "timestamp": datetime.utcnow().isoformat()
208
  })
209
 
210
- # Update memory and graph in background; don't block client completion.
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.add_episode(
217
- f"User asked: {message_content}\nAssistant answered: {full_response}",
 
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()
backend/app/schemas_memory.py CHANGED
@@ -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")
backend/app/services/assistant_service.py CHANGED
@@ -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 (Ollama MedGemma / Gemini)
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
- rag_context, 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_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 (placeholder logic, usually LLM would provide them structured)
109
- citations = self._extract_citations(rag_context)
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(rag_context),
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: (rag_text, memories, graph_facts)
134
  """
135
- # A. RAG Retrieval (Documents & Observations)
136
- # Note: get_user_context returns a formatted string, we might want raw docs too if we want citations
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.search(query, limit=5)
145
 
146
- return rag_text, memories, graph_facts
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(rag_context)
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.add_episode(
201
- f"User asked: {user_msg}\nAssistant answered: {assistant_msg}",
 
202
  source="user_chat"
203
  )
204
  except Exception as e:
205
  logger.error(f"Error updating memory/graph: {e}")
206
 
207
- def _extract_citations(self, rag_context_str: str) -> List[Citation]:
 
 
 
 
 
208
  """
209
- Attempt to reconstruct citations from the RAG context string.
210
- This is a simplification. Ideally, we pass the raw RAG docs through.
211
  """
212
- # Since we only have the string from `get_user_context`, we can't easily rebuild
213
- # precise Citation objects without parsing or changing RAGService to return structured data.
214
- # For MVP, we return empty citations or generic ones if we detect report content.
215
- citations = []
216
- if "From report" in rag_context_str:
217
- # Minimal dummy citation to indicate source usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  citations.append(Citation(
219
  report_id=None,
220
- metric_name="General",
221
- value="Referenced Report",
222
- excerpt="See context for details"
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(
backend/app/services/document_processing.py CHANGED
@@ -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.add_episode(
610
- f"User uploaded medical report: {report.filename} (Type: {report.doc_type})",
 
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
- # Format observations for graph
623
- obs_texts = []
624
- for obs in observations:
625
- status = "Abnormal" if obs.is_abnormal else "Normal"
 
 
 
 
 
 
 
 
 
 
626
  obs_texts.append(
627
- f"{obs.metric_name}: {obs.value} {obs.unit} ({status})"
 
628
  )
629
 
630
- await graph_service.add_observations(
631
- obs_texts,
 
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:
backend/app/services/enhanced_report_service.py CHANGED
@@ -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)
backend/app/services/graph_service.py CHANGED
@@ -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
- logger.info("Initializing Graphiti with Neo4j and Ollama...")
57
-
58
- # 1. Initialize LLM Client (Ollama via OpenAI compatible endpoint)
59
- llm_config = LLMConfig(
60
- api_key="ollama", # Dummy key for Ollama
61
- base_url=f"{settings.OLLAMA_BASE_URL}/v1",
62
- model=settings.OLLAMA_MODEL,
63
- temperature=0.1,
64
- max_tokens=4096
65
- )
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  llm_client = OpenAIClient(
68
- config=llm_config
 
69
  )
70
 
71
- # 2. Initialize Graphiti Client with Neo4j connection
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async def add_episode(self, content: str, source: str = "user_input", timestamp: Optional[str] = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- await asyncio.to_thread(_sync_add)
 
 
 
 
 
 
 
 
 
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 search(self, query: str, limit: int = 10) -> List[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  """
131
  Search the knowledge graph for relevant facts.
132
  """
133
  if not self.client:
134
  return []
135
 
136
- def _sync_search():
137
- # Graphiti search returns SearchResult objects
138
- results = self.client.search(query, limit=limit)
 
 
 
 
 
 
 
 
 
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
- formatted_results.append(str(item))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Graphiti driver doesn't explicitly expose close, but we can try
163
- if self.client and self.client.graph_driver:
164
- try:
165
- if hasattr(self.client.graph_driver, 'close'):
166
- await asyncio.to_thread(self.client.graph_driver.close)
167
- elif hasattr(self.client.graph_driver, 'driver'):
168
- await asyncio.to_thread(self.client.graph_driver.driver.close)
169
- logger.info("Graphiti driver closed")
170
- except Exception as e:
171
- logger.warning(f"Error closing graph driver: {e}")
 
 
 
 
 
 
 
 
 
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()
backend/app/services/grok_recommendation_service.py CHANGED
@@ -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.search(
 
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
  )
backend/app/services/llm_service.py CHANGED
@@ -1,11 +1,16 @@
1
  """
2
- LLM Service - Ollama streaming with Gemini fallback
3
 
4
- Provides async streaming generation using MedGemma via Ollama,
5
- with automatic fallback to Gemini API if Ollama is unavailable.
 
 
 
 
 
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
- # Medical system prompt for the LLM
21
- MEDICAL_SYSTEM_PROMPT = """You are a knowledgeable and empathetic AI medical health assistant.
 
 
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
- Primary: Ollama (MedGemma)
41
- Fallback: Gemini API
 
 
 
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
- # Try to list models to check connectivity
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(f"Ollama not available at {base_url}: {e}")
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
- """Determine which provider to use. Returns 'ollama', 'gemini', or 'disabled'."""
116
- # If Gemini is configured, use it directly - no need to check Ollama
 
 
 
 
 
 
 
 
117
  if settings.USE_GEMINI_FALLBACK and settings.GEMINI_API_KEY:
118
- logger.info("Using Gemini API (configured)")
119
  return "gemini"
120
-
121
- # Only check Ollama if Gemini is not configured
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
- # Build the full prompt
152
- full_prompt = self._build_prompt(user_message, context, chat_history)
153
-
154
- if provider == "ollama":
155
- async for token in self._stream_ollama(full_prompt):
156
  yield token
157
  elif provider == "gemini":
158
- async for token in self._stream_gemini(full_prompt):
 
 
 
 
 
159
  yield token
160
  else:
161
  yield (
162
  "LLM is not configured. "
163
- "Start Ollama (and set `OLLAMA_BASE_URL`/`OLLAMA_MODEL`) "
164
- "or set `GEMINI_API_KEY` with `USE_GEMINI_FALLBACK=true`."
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 the full prompt with system instructions, context, and history."""
174
- prompt_parts = [MEDICAL_SYSTEM_PROMPT]
175
-
176
  if context:
177
- prompt_parts.append(f"\n\n--- USER'S HEALTH DATA ---\n{context}\n--- END HEALTH DATA ---")
178
-
179
  if chat_history:
180
- prompt_parts.append("\n\n--- CONVERSATION HISTORY ---")
181
- for msg in chat_history[-10:]: # Last 10 messages
182
  role = "User" if msg.get("role") == "user" else "Assistant"
183
- prompt_parts.append(f"{role}: {msg.get('content', '')}")
184
- prompt_parts.append("--- END HISTORY ---")
185
-
186
- prompt_parts.append(f"\n\nUser: {user_message}\n\nAssistant:")
187
-
188
- return "\n".join(prompt_parts)
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(f"Ollama streaming error: {e}")
215
- # Mark as unavailable and try fallback
216
  self._ollama_available = False
217
- if settings.USE_GEMINI_FALLBACK and settings.GEMINI_API_KEY:
218
- logger.info("Falling back to Gemini after Ollama error")
219
- async for token in self._stream_gemini(prompt):
220
- yield token
221
- else:
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(f"Gemini streaming error: {e}")
251
- yield f"Error: Unable to generate response. {str(e)}"
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
- # Singleton instance
 
 
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
 
backend/app/services/memory_service.py CHANGED
@@ -1,7 +1,11 @@
1
  import logging
2
  import asyncio
 
 
3
  import threading
4
- from typing import List, Dict, Any, Optional
 
 
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
- self.config = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  "vector_store": {
46
  "provider": "chroma",
47
  "config": {
48
  "collection_name": settings.MEM0_COLLECTION,
49
- "path": settings.CHROMA_PERSIST_DIR,
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
- # Lazy initialization of the Memory client
71
- self._memory_cls = _import_mem0_memory()
72
- self.memory_client = None
73
- self._client_lock = threading.Lock()
74
- self._initialized = True
75
- logger.info("MemoryService initialized (lazy loading client)")
76
 
77
- @property
78
- def is_available(self) -> bool:
79
- return self._memory_cls is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.memory_client = self._memory_cls.from_config(self.config)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(self, content: str, user_id: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
 
 
 
 
 
 
 
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
- try:
114
- return await asyncio.to_thread(_sync_add)
115
- except Exception as e:
116
- logger.error(f"Error adding memory for user {user_id}: {e}")
117
- return {"error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return await asyncio.to_thread(_sync_search)
 
 
134
  except Exception as e:
135
- logger.error(f"Error searching memories for user {user_id}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return await asyncio.to_thread(_sync_get_all)
 
 
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
- return await asyncio.to_thread(_sync_delete)
 
 
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
- return await asyncio.to_thread(_sync_delete_all)
 
 
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
backend/app/services/rag_service.py CHANGED
@@ -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
  """
backend/app/settings.py CHANGED
@@ -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
- # Ollama LLM Configuration
 
 
 
 
 
 
 
 
 
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}")
 
backend/requirements.txt CHANGED
@@ -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>=4.56.0
 
 
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 with Gemini support)
57
- graphiti-core[google-genai]>=0.5.0
 
 
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
backend/tests/test_assistant_integration.py CHANGED
@@ -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.search = AsyncMock(return_value=["HbA1c -> indicates -> Diabetes"])
48
- graph_service.add_episode = AsyncMock(return_value=None)
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()
backend/tests/test_assistant_service.py CHANGED
@@ -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.search.return_value = ["Diabetes relates to High Glucose"]
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"].search.assert_called_once_with(message, limit=5)
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"].add_episode.assert_called_once()
149
- args, _ = mock_services["graph"].add_episode.call_args
150
- assert "User asked: I feel tired" in args[0]
 
 
 
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"]
backend/tests/test_graph_service.py CHANGED
@@ -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.assert_called_once_with(
26
- name=f"Episode from {source}",
27
- episode_body=content,
28
- source_description=source,
29
- reference_time=None
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.assert_called_once_with(
44
- name=f"Episode from {source}",
45
- episode_body=expected_content,
46
- source_description=source,
47
- reference_time=None
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.assert_called_once_with(query, limit=10)
 
 
 
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.assert_called_once()
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
backend/tests/test_routes_graph.py CHANGED
@@ -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.search = AsyncMock(return_value=[
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.search.assert_called_once()
 
 
 
 
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.search.assert_called_once_with(
 
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.search = AsyncMock(side_effect=Exception("Neo4j connection error"))
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
backend/tests/test_routes_memory.py CHANGED
@@ -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."""
docker-compose.yml CHANGED
@@ -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:
frontend/src/components/HealthGraph.css CHANGED
@@ -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
  }
frontend/src/components/HealthGraph.tsx CHANGED
@@ -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[], relationships: GraphRelationship[]) => {
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 = graphData
158
- ? calculateLayout(graphData.nodes, graphData.relationships)
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 health relationships yet</p>
214
- <span>Relationships will be discovered as you chat with the assistant.</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </div>
216
  ) : (
217
  <>
@@ -228,13 +366,102 @@ const HealthGraph: React.FC<HealthGraphProps> = ({
228
  <Maximize2 size={16} />
229
  </button>
230
  </div>
231
- <button
232
- className="health-graph-refresh"
233
- onClick={fetchGraphData}
234
- title="Refresh"
235
- >
236
- <RefreshCw size={16} />
237
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.toLowerCase().replace(/ /g, '_')];
258
- const targetPos = nodePositions[rel.target.toLowerCase().replace(/ /g, '_')];
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
 
frontend/src/components/ProfileSuccessScreen.tsx CHANGED
@@ -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
- // Sync profile data to memory/graph layers for provenance
27
- const syncToMemory = async () => {
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('/dashboard');
53
  }, redirectDelay);
54
 
55
  return () => clearTimeout(timer);
56
  }
57
  }, [autoRedirect, redirectDelay, navigate]);
58
 
59
- const handleDashboard = () => {
60
- navigate('/dashboard');
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={handleDashboard}
144
  >
145
- Go to Dashboard
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 dashboard...
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>
frontend/src/components/dashboard/DashboardNavbar.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useState } from 'react';
2
  import { Link, useLocation, useNavigate } from 'react-router-dom';
3
  import { motion, AnimatePresence } from 'framer-motion';
4
- import { Settings, LogOut, Home, LayoutDashboard, 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"
frontend/src/pages/Features.css CHANGED
@@ -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
  }
frontend/src/pages/Features.tsx CHANGED
@@ -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
- }, [navigate]);
 
 
 
 
 
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;