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

Add code for Integrate memory and knowledge graph services with profile data synchronization and new dashboard components.

Browse files
backend/alembic/versions/8f1b6c345678_add_profile_completion_columns.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add profile completion columns
2
+
3
+ Revision ID: 8f1b6c345678
4
+ Revises: 7e0a5b234567
5
+ Create Date: 2025-02-06
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '8f1b6c345678'
16
+ down_revision: Union[str, None] = '7e0a5b234567'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
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:
31
+ op.drop_column('user_profiles', 'completed_at')
32
+ op.drop_column('user_profiles', 'is_completed')
backend/app/main.py CHANGED
@@ -7,7 +7,7 @@ from app.settings import settings
7
  from app.db import init_db, engine
8
  from app.services.graph_service import get_graph_service
9
  from app.services.reminder_scheduler import start_reminder_scheduler, stop_reminder_scheduler
10
- from app.routes import auth, health, dashboard, reports, assistant, recommendations
11
  from app.routes.profile import router as profile_router
12
  from app.routes.profile_me import router as profile_me_router
13
  from app.routes.websocket import router as websocket_router
@@ -107,6 +107,8 @@ app.include_router(documents_router)
107
  app.include_router(ai_summary_router)
108
  app.include_router(medicines_router)
109
  app.include_router(voice_router)
 
 
110
 
111
  @app.get("/")
112
  async def root():
@@ -126,6 +128,8 @@ async def root():
126
  "reminders": "/api/reminders",
127
  "sms": "/api/sms",
128
  "ai_summary": "/api/ai",
 
 
129
  "websocket": "ws://localhost:8000/ws?token=<jwt>"
130
  }
131
  }
 
7
  from app.db import init_db, engine
8
  from app.services.graph_service import get_graph_service
9
  from app.services.reminder_scheduler import start_reminder_scheduler, stop_reminder_scheduler
10
+ from app.routes import auth, health, dashboard, reports, assistant, recommendations, memory, graph
11
  from app.routes.profile import router as profile_router
12
  from app.routes.profile_me import router as profile_me_router
13
  from app.routes.websocket import router as websocket_router
 
107
  app.include_router(ai_summary_router)
108
  app.include_router(medicines_router)
109
  app.include_router(voice_router)
110
+ app.include_router(memory.router)
111
+ app.include_router(graph.router)
112
 
113
  @app.get("/")
114
  async def root():
 
128
  "reminders": "/api/reminders",
129
  "sms": "/api/sms",
130
  "ai_summary": "/api/ai",
131
+ "memory": "/api/memory",
132
+ "graph": "/api/graph",
133
  "websocket": "ws://localhost:8000/ws?token=<jwt>"
134
  }
135
  }
backend/app/routes/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Routes module for the Lumea Health Platform API.
3
  """
4
- from . import auth, health, dashboard, reports, assistant, recommendations, websocket, ai_summary, medicines
5
 
6
- __all__ = ['auth', 'health', 'dashboard', 'reports', 'assistant', 'recommendations', 'websocket', 'ai_summary', 'medicines']
 
1
  """
2
  Routes module for the Lumea Health Platform API.
3
  """
4
+ from . import auth, health, dashboard, reports, assistant, recommendations, websocket, ai_summary, medicines, memory, graph
5
 
6
+ __all__ = ['auth', 'health', 'dashboard', 'reports', 'assistant', 'recommendations', 'websocket', 'ai_summary', 'medicines', 'memory', 'graph']
backend/app/routes/graph.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Graph API Routes
3
+
4
+ Exposes Neo4j/Graphiti knowledge graph to frontend for viewing health relationships
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,
19
+ GraphRelationship,
20
+ GraphFactsResponse,
21
+ GraphSearchRequest,
22
+ GraphSearchResponse,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
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.
33
+
34
+ Format: "Source -> relation -> Target"
35
+ """
36
+ try:
37
+ if " -> " in result:
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
+
47
+ # Fallback: treat entire string as a single fact
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
+
62
+
63
+ @router.get("/facts", response_model=GraphFactsResponse)
64
+ async def get_user_graph_facts(
65
+ query: str = Query("health conditions medications recommendations", description="Search query for facts"),
66
+ limit: int = Query(20, ge=1, le=50, description="Max results"),
67
+ current_user: User = Depends(get_current_user),
68
+ ) -> GraphFactsResponse:
69
+ """
70
+ Get health facts/relationships from the knowledge graph for the current user.
71
+
72
+ Returns structured relationships like:
73
+ - "High LDL -> leads_to -> Cardiovascular Risk"
74
+ - "User -> has_condition -> Diabetes"
75
+ """
76
+ graph_service = get_graph_service()
77
+
78
+ # Check if Graphiti is available
79
+ if graph_service.client is None:
80
+ return GraphFactsResponse(
81
+ facts=[],
82
+ count=0,
83
+ available=False,
84
+ message="Knowledge graph service is not available"
85
+ )
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:
93
+ try:
94
+ facts.append(_parse_graph_result_to_relationship(result))
95
+ except Exception as e:
96
+ logger.warning(f"Failed to parse graph result: {e}")
97
+ continue
98
+
99
+ return GraphFactsResponse(
100
+ facts=facts,
101
+ count=len(facts),
102
+ available=True,
103
+ message=None
104
+ )
105
+
106
+ except Exception as e:
107
+ logger.error(f"Error fetching graph facts for user {current_user.id}: {e}")
108
+ return GraphFactsResponse(
109
+ facts=[],
110
+ count=0,
111
+ available=True,
112
+ message=f"Error fetching graph facts: {str(e)}"
113
+ )
114
+
115
+
116
+ @router.post("/search", response_model=GraphSearchResponse)
117
+ async def search_graph(
118
+ request: GraphSearchRequest,
119
+ current_user: User = Depends(get_current_user),
120
+ ) -> GraphSearchResponse:
121
+ """
122
+ Search the knowledge graph for relevant health information.
123
+
124
+ Uses semantic search to find relationships and facts.
125
+ """
126
+ graph_service = get_graph_service()
127
+
128
+ if graph_service.client is None:
129
+ return GraphSearchResponse(
130
+ results=[],
131
+ count=0,
132
+ available=False,
133
+ message="Knowledge graph service is not available"
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
147
+ )
148
+
149
+ except Exception as e:
150
+ logger.error(f"Error searching graph for user {current_user.id}: {e}")
151
+ return GraphSearchResponse(
152
+ results=[],
153
+ count=0,
154
+ available=True,
155
+ message=f"Error searching graph: {str(e)}"
156
+ )
157
+
158
+
159
+ @router.get("/relationships", response_model=GraphDataResponse)
160
+ async def get_graph_visualization_data(
161
+ query: str = Query("health metrics conditions recommendations", description="Search query"),
162
+ limit: int = Query(30, ge=1, le=100, description="Max relationships to return"),
163
+ current_user: User = Depends(get_current_user),
164
+ ) -> GraphDataResponse:
165
+ """
166
+ Get graph data formatted for visualization.
167
+
168
+ Returns nodes and relationships that can be rendered as an interactive graph.
169
+ """
170
+ graph_service = get_graph_service()
171
+
172
+ if graph_service.client is None:
173
+ return GraphDataResponse(
174
+ nodes=[],
175
+ relationships=[],
176
+ total_nodes=0,
177
+ total_relationships=0,
178
+ available=False,
179
+ message="Knowledge graph service is not available"
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
187
+ relationships = []
188
+
189
+ for result in results:
190
+ rel = _parse_graph_result_to_relationship(result)
191
+ relationships.append(rel)
192
+
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={}
200
+ )
201
+
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={}
209
+ )
210
+
211
+ nodes = list(nodes_dict.values())
212
+
213
+ return GraphDataResponse(
214
+ nodes=nodes,
215
+ relationships=relationships,
216
+ total_nodes=len(nodes),
217
+ total_relationships=len(relationships),
218
+ available=True,
219
+ message=None
220
+ )
221
+
222
+ except Exception as e:
223
+ logger.error(f"Error fetching graph visualization data: {e}")
224
+ return GraphDataResponse(
225
+ nodes=[],
226
+ relationships=[],
227
+ total_nodes=0,
228
+ total_relationships=0,
229
+ available=True,
230
+ message=f"Error fetching graph data: {str(e)}"
231
+ )
232
+
233
+
234
+ def _infer_node_type(node_name: str) -> str:
235
+ """
236
+ Infer the type of a node based on its name.
237
+
238
+ Used for styling in the frontend visualization.
239
+ """
240
+ name_lower = node_name.lower()
241
+
242
+ if any(kw in name_lower for kw in ["diabetes", "hypertension", "disease", "condition", "syndrome"]):
243
+ return "condition"
244
+ elif any(kw in name_lower for kw in ["cholesterol", "glucose", "ldl", "hdl", "triglyceride", "hba1c", "vitamin"]):
245
+ return "metric"
246
+ elif any(kw in name_lower for kw in ["medication", "drug", "metformin", "statin"]):
247
+ return "medication"
248
+ elif any(kw in name_lower for kw in ["recommend", "risk", "alert", "warning"]):
249
+ return "recommendation"
250
+ elif any(kw in name_lower for kw in ["user", "patient"]):
251
+ return "user"
252
+ else:
253
+ return "entity"
backend/app/routes/memory.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memory API Routes
3
+
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
15
+ from app.schemas_memory import (
16
+ MemoryListResponse,
17
+ MemoryFact,
18
+ MemoryCreateRequest,
19
+ MemoryCreateResponse,
20
+ MemoryDeleteResponse,
21
+ MemorySearchRequest,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
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.
32
+
33
+ Mem0 can return different formats depending on version/operation.
34
+ """
35
+ if isinstance(item, dict):
36
+ return MemoryFact(
37
+ id=item.get("id", item.get("memory_id", "")),
38
+ memory=item.get("memory", item.get("text", item.get("content", str(item)))),
39
+ created_at=item.get("created_at"),
40
+ metadata=item.get("metadata", {})
41
+ )
42
+ else:
43
+ # Handle string or other formats
44
+ return MemoryFact(
45
+ id=str(hash(str(item))),
46
+ memory=str(item),
47
+ created_at=None,
48
+ metadata={}
49
+ )
50
+
51
+
52
+ @router.get("/facts", response_model=MemoryListResponse)
53
+ async def get_user_memories(
54
+ current_user: User = Depends(get_current_user),
55
+ ) -> MemoryListResponse:
56
+ """
57
+ Get all stored memories/facts for the current user.
58
+
59
+ Returns memories from Mem0 that capture user preferences, facts,
60
+ and relevant information extracted from conversations.
61
+ """
62
+ memory_service = get_memory_service()
63
+
64
+ if not memory_service.is_available:
65
+ return MemoryListResponse(
66
+ facts=[],
67
+ total_count=0,
68
+ available=False,
69
+ message="Memory service (Mem0) is not available"
70
+ )
71
+
72
+ try:
73
+ memories = await memory_service.get_all(user_id=str(current_user.id))
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):
81
+ memory_list = memories
82
+ else:
83
+ memory_list = []
84
+
85
+ for item in memory_list:
86
+ try:
87
+ facts.append(_normalize_memory_item(item))
88
+ except Exception as e:
89
+ logger.warning(f"Failed to normalize memory item: {e}")
90
+ continue
91
+
92
+ return MemoryListResponse(
93
+ facts=facts,
94
+ total_count=len(facts),
95
+ available=True,
96
+ message=None
97
+ )
98
+
99
+ except Exception as e:
100
+ logger.error(f"Error fetching memories for user {current_user.id}: {e}")
101
+ return MemoryListResponse(
102
+ facts=[],
103
+ total_count=0,
104
+ available=True,
105
+ message=f"Error fetching memories: {str(e)}"
106
+ )
107
+
108
+
109
+ @router.post("/facts", response_model=MemoryCreateResponse)
110
+ async def create_memory(
111
+ request: MemoryCreateRequest,
112
+ current_user: User = Depends(get_current_user),
113
+ ) -> MemoryCreateResponse:
114
+ """
115
+ Manually add a memory/fact for the current user.
116
+
117
+ This allows users to explicitly store preferences or facts
118
+ that the system should remember.
119
+ """
120
+ memory_service = get_memory_service()
121
+
122
+ if not memory_service.is_available:
123
+ return MemoryCreateResponse(
124
+ success=False,
125
+ id=None,
126
+ message="Memory service (Mem0) is not available"
127
+ )
128
+
129
+ try:
130
+ result = await memory_service.add(
131
+ content=request.content,
132
+ user_id=str(current_user.id),
133
+ metadata=request.metadata
134
+ )
135
+
136
+ if isinstance(result, dict) and "error" in result:
137
+ return MemoryCreateResponse(
138
+ success=False,
139
+ id=None,
140
+ message=result["error"]
141
+ )
142
+
143
+ # Extract ID from result
144
+ memory_id = None
145
+ if isinstance(result, dict):
146
+ memory_id = result.get("id", result.get("memory_id"))
147
+
148
+ return MemoryCreateResponse(
149
+ success=True,
150
+ id=memory_id,
151
+ message="Memory created successfully"
152
+ )
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error creating memory for user {current_user.id}: {e}")
156
+ return MemoryCreateResponse(
157
+ success=False,
158
+ id=None,
159
+ message=str(e)
160
+ )
161
+
162
+
163
+ @router.delete("/facts/{memory_id}", response_model=MemoryDeleteResponse)
164
+ async def delete_memory(
165
+ memory_id: str,
166
+ current_user: User = Depends(get_current_user),
167
+ ) -> MemoryDeleteResponse:
168
+ """
169
+ Delete a specific memory by ID.
170
+
171
+ Allows users to remove memories they don't want the system to use.
172
+ """
173
+ memory_service = get_memory_service()
174
+
175
+ if not memory_service.is_available:
176
+ return MemoryDeleteResponse(
177
+ success=False,
178
+ deleted_count=0,
179
+ message="Memory service (Mem0) is not available"
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:
192
+ logger.error(f"Error deleting memory {memory_id}: {e}")
193
+ return MemoryDeleteResponse(
194
+ success=False,
195
+ deleted_count=0,
196
+ message=str(e)
197
+ )
198
+
199
+
200
+ @router.delete("/facts", response_model=MemoryDeleteResponse)
201
+ async def delete_all_memories(
202
+ current_user: User = Depends(get_current_user),
203
+ ) -> MemoryDeleteResponse:
204
+ """
205
+ Delete all memories for the current user.
206
+
207
+ This is a destructive operation - use with caution.
208
+ """
209
+ memory_service = get_memory_service()
210
+
211
+ if not memory_service.is_available:
212
+ return MemoryDeleteResponse(
213
+ success=False,
214
+ deleted_count=0,
215
+ message="Memory service (Mem0) is not available"
216
+ )
217
+
218
+ try:
219
+ success = await memory_service.delete_all(user_id=str(current_user.id))
220
+
221
+ return MemoryDeleteResponse(
222
+ success=success,
223
+ deleted_count=-1, # Unknown count for delete_all
224
+ message="All memories deleted successfully" if success else "Failed to delete memories"
225
+ )
226
+
227
+ except Exception as e:
228
+ logger.error(f"Error deleting all memories for user {current_user.id}: {e}")
229
+ return MemoryDeleteResponse(
230
+ success=False,
231
+ deleted_count=0,
232
+ message=str(e)
233
+ )
234
+
235
+
236
+ @router.post("/search")
237
+ async def search_memories(
238
+ request: MemorySearchRequest,
239
+ current_user: User = Depends(get_current_user),
240
+ ) -> MemoryListResponse:
241
+ """
242
+ Search user memories by query.
243
+
244
+ Uses semantic search to find relevant memories.
245
+ """
246
+ memory_service = get_memory_service()
247
+
248
+ if not memory_service.is_available:
249
+ return MemoryListResponse(
250
+ facts=[],
251
+ total_count=0,
252
+ available=False,
253
+ message="Memory service (Mem0) is not available"
254
+ )
255
+
256
+ try:
257
+ results = await memory_service.search(
258
+ query=request.query,
259
+ user_id=str(current_user.id),
260
+ limit=request.limit
261
+ )
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:
270
+ logger.warning(f"Failed to normalize search result: {e}")
271
+ continue
272
+
273
+ return MemoryListResponse(
274
+ facts=facts,
275
+ total_count=len(facts),
276
+ available=True,
277
+ message=None
278
+ )
279
+
280
+ except Exception as e:
281
+ logger.error(f"Error searching memories for user {current_user.id}: {e}")
282
+ return MemoryListResponse(
283
+ facts=[],
284
+ total_count=0,
285
+ available=True,
286
+ message=f"Error searching memories: {str(e)}"
287
+ )
backend/app/routes/profile.py CHANGED
@@ -588,3 +588,107 @@ async def update_wizard_state(
588
  "wizard_completed": profile.wizard_completed,
589
  "wizard_last_saved_at": profile.wizard_last_saved_at.isoformat() if profile.wizard_last_saved_at else None
590
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  "wizard_completed": profile.wizard_completed,
589
  "wizard_last_saved_at": profile.wizard_last_saved_at.isoformat() if profile.wizard_last_saved_at else None
590
  }
591
+
592
+
593
+ # ============================================================================
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),
600
+ db: AsyncSession = Depends(get_db)
601
+ ):
602
+ """
603
+ Sync user profile data to Memory (Mem0) and Knowledge Graph (Neo4j).
604
+
605
+ Called after questionnaire completion to ensure all health data
606
+ is available for the recommendation engine's provenance tracking.
607
+ """
608
+ from app.services.memory_service import get_memory_service
609
+ from app.services.graph_service import get_graph_service
610
+
611
+ service = ProfileService(db, current_user)
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
+
backend/app/routes/recommendations.py CHANGED
@@ -167,6 +167,11 @@ async def get_stored_recommendations(
167
 
168
  items = []
169
  for rec in recommendations:
 
 
 
 
 
170
  items.append({
171
  "id": str(rec.id),
172
  "title": rec.title,
@@ -175,6 +180,7 @@ async def get_stored_recommendations(
175
  "priority": _priority_to_label(rec.priority),
176
  "actions": rec.evidence_jsonb.get("actions", []) if rec.evidence_jsonb else [],
177
  "evidence": rec.evidence_jsonb.get("evidence", []) if rec.evidence_jsonb else [],
 
178
  "created_at": rec.created_at.isoformat() if rec.created_at else None,
179
  "is_completed": rec.completed_at is not None
180
  })
 
167
 
168
  items = []
169
  for rec in recommendations:
170
+ # Extract provenance from evidence_jsonb
171
+ provenance = {}
172
+ if rec.evidence_jsonb:
173
+ provenance = rec.evidence_jsonb.get("provenance", {})
174
+
175
  items.append({
176
  "id": str(rec.id),
177
  "title": rec.title,
 
180
  "priority": _priority_to_label(rec.priority),
181
  "actions": rec.evidence_jsonb.get("actions", []) if rec.evidence_jsonb else [],
182
  "evidence": rec.evidence_jsonb.get("evidence", []) if rec.evidence_jsonb else [],
183
+ "provenance": provenance, # NEW: Include provenance for tooltip
184
  "created_at": rec.created_at.isoformat() if rec.created_at else None,
185
  "is_completed": rec.completed_at is not None
186
  })
backend/app/schemas_memory.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memory and Graph API Schemas
3
+
4
+ Pydantic models for memory (Mem0) and knowledge graph (Neo4j/Graphiti) endpoints.
5
+ """
6
+ from pydantic import BaseModel, Field, ConfigDict
7
+ from datetime import datetime
8
+ from typing import Optional, List, Dict, Any
9
+ from uuid import UUID
10
+
11
+
12
+ # ============================================================================
13
+ # MEMORY (Mem0) SCHEMAS
14
+ # ============================================================================
15
+
16
+ class MemoryFact(BaseModel):
17
+ """Single memory item from Mem0."""
18
+ id: str = Field(..., description="Unique memory ID")
19
+ memory: str = Field(..., description="The stored memory/fact text")
20
+ created_at: Optional[datetime] = Field(None, description="When the memory was created")
21
+ metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional metadata")
22
+
23
+ model_config = ConfigDict(from_attributes=True)
24
+
25
+
26
+ class MemoryListResponse(BaseModel):
27
+ """Response containing list of user memories."""
28
+ facts: List[MemoryFact] = Field(default_factory=list, description="List of memory facts")
29
+ total_count: int = Field(0, description="Total number of memories")
30
+ available: bool = Field(True, description="Whether Mem0 service is available")
31
+ message: Optional[str] = Field(None, description="Optional status message")
32
+
33
+
34
+ class MemoryCreateRequest(BaseModel):
35
+ """Request to create a new memory."""
36
+ content: str = Field(..., min_length=1, max_length=5000, description="Memory content to store")
37
+ metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Optional metadata")
38
+
39
+
40
+ class MemoryCreateResponse(BaseModel):
41
+ """Response after creating a memory."""
42
+ success: bool = Field(..., description="Whether creation succeeded")
43
+ id: Optional[str] = Field(None, description="ID of created memory")
44
+ message: Optional[str] = Field(None, description="Status message")
45
+
46
+
47
+ class MemoryDeleteResponse(BaseModel):
48
+ """Response after deleting memory/memories."""
49
+ success: bool = Field(..., description="Whether deletion succeeded")
50
+ deleted_count: int = Field(0, description="Number of memories deleted")
51
+ message: Optional[str] = Field(None, description="Status message")
52
+
53
+
54
+ class MemorySearchRequest(BaseModel):
55
+ """Request to search memories."""
56
+ query: str = Field(..., min_length=1, max_length=500, description="Search query")
57
+ limit: int = Field(5, ge=1, le=20, description="Max results to return")
58
+
59
+
60
+ # ============================================================================
61
+ # KNOWLEDGE GRAPH (Neo4j/Graphiti) SCHEMAS
62
+ # ============================================================================
63
+
64
+ class GraphNode(BaseModel):
65
+ """A node in the knowledge graph."""
66
+ id: str = Field(..., description="Node identifier")
67
+ name: str = Field(..., description="Node name/label")
68
+ type: str = Field("entity", description="Node type (condition, metric, recommendation, etc.)")
69
+ properties: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Node properties")
70
+
71
+
72
+ class GraphRelationship(BaseModel):
73
+ """A relationship/edge in the knowledge graph."""
74
+ source: str = Field(..., description="Source node name")
75
+ relation: str = Field(..., description="Relationship type")
76
+ target: str = Field(..., description="Target node name")
77
+ properties: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Relationship properties")
78
+
79
+
80
+ class GraphDataResponse(BaseModel):
81
+ """Full graph data for visualization."""
82
+ nodes: List[GraphNode] = Field(default_factory=list, description="Graph nodes")
83
+ relationships: List[GraphRelationship] = Field(default_factory=list, description="Graph edges")
84
+ total_nodes: int = Field(0, description="Total node count")
85
+ total_relationships: int = Field(0, description="Total relationship count")
86
+ available: bool = Field(True, description="Whether graph service is available")
87
+ message: Optional[str] = Field(None, description="Optional status message")
88
+
89
+
90
+ class GraphSearchRequest(BaseModel):
91
+ """Request to search the knowledge graph."""
92
+ query: str = Field(..., min_length=1, max_length=500, description="Search query")
93
+ limit: int = Field(10, ge=1, le=50, description="Max results to return")
94
+
95
+
96
+ class GraphSearchResponse(BaseModel):
97
+ """Response from graph search."""
98
+ results: List[str] = Field(default_factory=list, description="Search results as formatted strings")
99
+ count: int = Field(0, description="Number of results")
100
+ available: bool = Field(True, description="Whether graph service is available")
101
+ message: Optional[str] = Field(None, description="Optional status message")
102
+
103
+
104
+ class GraphFactsResponse(BaseModel):
105
+ """User's health facts from the knowledge graph."""
106
+ facts: List[GraphRelationship] = Field(default_factory=list, description="Health relationships/facts")
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")
backend/app/services/grok_recommendation_service.py CHANGED
@@ -19,6 +19,9 @@ from app.models import (
19
  )
20
  from app.settings import settings
21
  from app.services.recommendation_service import get_user_recommendations
 
 
 
22
 
23
  logger = logging.getLogger(__name__)
24
 
@@ -111,7 +114,14 @@ async def generate_grok_recommendations(
111
 
112
 
113
  def _build_grok_prompt(context: Dict[str, Any]) -> str:
114
- """Build prompt for Grok API based on user health data."""
 
 
 
 
 
 
 
115
 
116
  prompt_parts = [
117
  "Analyze the following health profile and generate personalized recommendations.",
@@ -162,6 +172,46 @@ def _build_grok_prompt(context: Dict[str, Any]) -> str:
162
  for factor, score in sorted(contributions.items(), key=lambda x: x[1], reverse=True)[:5]:
163
  prompt_parts.append(f"- {factor}: {score}")
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  prompt_parts.append("")
166
  prompt_parts.append("**Required Output Format:**")
167
  prompt_parts.append("Generate 3-7 recommendations in this JSON array format:")
@@ -225,9 +275,10 @@ def _parse_grok_response(content: str) -> List[Dict[str, Any]]:
225
  async def save_recommendations_to_db(
226
  user_id: str,
227
  recommendations: List[Dict[str, Any]],
228
- db: AsyncSession
 
229
  ) -> None:
230
- """Save generated recommendations to database."""
231
 
232
  # Deactivate old recommendations
233
  result = await db.execute(
@@ -243,6 +294,16 @@ async def save_recommendations_to_db(
243
  priority_map = {"high": 1, "medium": 5, "low": 8}
244
 
245
  for rec_data in recommendations:
 
 
 
 
 
 
 
 
 
 
246
  new_rec = ProfileRecommendation(
247
  user_id=user_id,
248
  recommendation_type=rec_data.get("category", "lifestyle"),
@@ -253,7 +314,8 @@ async def save_recommendations_to_db(
253
  evidence_jsonb={
254
  "actions": rec_data.get("actions", []),
255
  "evidence": rec_data.get("evidence", []),
256
- "source": "grok_api"
 
257
  },
258
  is_active=True
259
  )
@@ -287,9 +349,9 @@ async def generate_and_save_recommendations(
287
  # Convert to our format for storage
288
  grok_recommendations = _convert_rule_recommendations(rule_recommendations)
289
 
290
- # Save to database
291
  if grok_recommendations:
292
- await save_recommendations_to_db(user_id, grok_recommendations, db)
293
 
294
  return {
295
  "count": len(grok_recommendations),
@@ -299,10 +361,22 @@ async def generate_and_save_recommendations(
299
 
300
 
301
  async def _gather_user_context(user_id: str, db: AsyncSession) -> Dict[str, Any]:
302
- """Gather all relevant user health data for recommendation generation."""
 
 
 
 
 
 
 
 
303
 
304
  context = {}
305
 
 
 
 
 
306
  # Get profile
307
  result = await db.execute(
308
  select(UserProfile).where(UserProfile.user_id == user_id)
@@ -373,6 +447,122 @@ async def _gather_user_context(user_id: str, db: AsyncSession) -> Dict[str, Any]
373
  "contributions": health_index.contributions or {}
374
  }
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  return context
377
 
378
 
 
19
  )
20
  from app.settings import settings
21
  from app.services.recommendation_service import get_user_recommendations
22
+ from app.services.memory_service import get_memory_service
23
+ from app.services.rag_service import get_rag_service
24
+ from app.services.graph_service import get_graph_service
25
 
26
  logger = logging.getLogger(__name__)
27
 
 
114
 
115
 
116
  def _build_grok_prompt(context: Dict[str, Any]) -> str:
117
+ """Build prompt for Grok API based on user health data.
118
+
119
+ Includes:
120
+ - User profile and conditions (from PostgreSQL)
121
+ - User preferences and facts (from Mem0)
122
+ - Historical context (from RAG)
123
+ - Medical relationships (from Neo4j/Graphiti)
124
+ """
125
 
126
  prompt_parts = [
127
  "Analyze the following health profile and generate personalized recommendations.",
 
172
  for factor, score in sorted(contributions.items(), key=lambda x: x[1], reverse=True)[:5]:
173
  prompt_parts.append(f"- {factor}: {score}")
174
 
175
+ # =========================================================================
176
+ # NEW: User Preferences from Mem0
177
+ # =========================================================================
178
+ user_preferences = context.get("user_preferences", [])
179
+ if user_preferences:
180
+ prompt_parts.append("")
181
+ prompt_parts.append("**User Preferences & Personal Context:** (IMPORTANT: Tailor recommendations to these)")
182
+ for pref in user_preferences[:7]: # Limit to avoid prompt bloat
183
+ prompt_parts.append(f"- {pref}")
184
+
185
+ # =========================================================================
186
+ # NEW: Historical Context from RAG
187
+ # =========================================================================
188
+ historical_context = context.get("historical_context", [])
189
+ if historical_context:
190
+ prompt_parts.append("")
191
+ prompt_parts.append("**Historical Health Data:**")
192
+ for snippet in historical_context[:5]: # Limit to avoid prompt bloat
193
+ # Truncate long snippets
194
+ display_snippet = snippet[:300] + "..." if len(snippet) > 300 else snippet
195
+ prompt_parts.append(f"- {display_snippet}")
196
+
197
+ # =========================================================================
198
+ # NEW: Medical Relationships from Neo4j/Graphiti
199
+ # =========================================================================
200
+ medical_relationships = context.get("medical_relationships", [])
201
+ if medical_relationships:
202
+ prompt_parts.append("")
203
+ prompt_parts.append("**Known Medical Relationships:**")
204
+ for rel in medical_relationships[:8]: # Limit to avoid prompt bloat
205
+ prompt_parts.append(f"- {rel}")
206
+
207
+ prompt_parts.append("")
208
+ prompt_parts.append("**IMPORTANT INSTRUCTIONS:**")
209
+ if user_preferences:
210
+ prompt_parts.append("- PERSONALIZE recommendations based on the user's preferences above")
211
+ prompt_parts.append("- Consider their lifestyle, dietary preferences, and stated concerns")
212
+ prompt_parts.append("- Be specific and actionable")
213
+ prompt_parts.append("- Prioritize based on health risk and user context")
214
+
215
  prompt_parts.append("")
216
  prompt_parts.append("**Required Output Format:**")
217
  prompt_parts.append("Generate 3-7 recommendations in this JSON array format:")
 
275
  async def save_recommendations_to_db(
276
  user_id: str,
277
  recommendations: List[Dict[str, Any]],
278
+ db: AsyncSession,
279
+ context: Optional[Dict[str, Any]] = None
280
  ) -> None:
281
+ """Save generated recommendations to database with provenance tracking."""
282
 
283
  # Deactivate old recommendations
284
  result = await db.execute(
 
294
  priority_map = {"high": 1, "medium": 5, "low": 8}
295
 
296
  for rec_data in recommendations:
297
+ # Build provenance info based on context
298
+ provenance = {}
299
+ if context:
300
+ provenance = {
301
+ "memory": bool(context.get("user_preferences")),
302
+ "graph": bool(context.get("medical_relationships")),
303
+ "metrics": bool(context.get("observations")),
304
+ "profile": bool(context.get("profile")),
305
+ }
306
+
307
  new_rec = ProfileRecommendation(
308
  user_id=user_id,
309
  recommendation_type=rec_data.get("category", "lifestyle"),
 
314
  evidence_jsonb={
315
  "actions": rec_data.get("actions", []),
316
  "evidence": rec_data.get("evidence", []),
317
+ "source": "grok_api",
318
+ "provenance": provenance,
319
  },
320
  is_active=True
321
  )
 
349
  # Convert to our format for storage
350
  grok_recommendations = _convert_rule_recommendations(rule_recommendations)
351
 
352
+ # Save to database with context for provenance
353
  if grok_recommendations:
354
+ await save_recommendations_to_db(user_id, grok_recommendations, db, context)
355
 
356
  return {
357
  "count": len(grok_recommendations),
 
361
 
362
 
363
  async def _gather_user_context(user_id: str, db: AsyncSession) -> Dict[str, Any]:
364
+ """Gather all relevant user health data for recommendation generation.
365
+
366
+ Integrates data from:
367
+ 1. PostgreSQL (profile, conditions, observations, health index)
368
+ 2. Mem0 (user preferences and facts from conversations)
369
+ 3. RAG (historical report context)
370
+ 4. Neo4j/Graphiti (medical knowledge relationships)
371
+ """
372
+ import asyncio
373
 
374
  context = {}
375
 
376
+ # =========================================================================
377
+ # 1. POSTGRESQL DATA (existing functionality)
378
+ # =========================================================================
379
+
380
  # Get profile
381
  result = await db.execute(
382
  select(UserProfile).where(UserProfile.user_id == user_id)
 
447
  "contributions": health_index.contributions or {}
448
  }
449
 
450
+ # =========================================================================
451
+ # 2. MEMORY/KNOWLEDGE LAYER (NEW - parallel async queries)
452
+ # =========================================================================
453
+
454
+ # Define async tasks for each service
455
+ async def get_memory_context():
456
+ """Fetch user preferences from Mem0."""
457
+ try:
458
+ memory_service = get_memory_service()
459
+ if not memory_service.is_available:
460
+ return []
461
+
462
+ # Search for preferences relevant to recommendations
463
+ results = await memory_service.search(
464
+ query="preferences diet exercise lifestyle medication health goals concerns",
465
+ user_id=user_id,
466
+ limit=10
467
+ )
468
+
469
+ # Extract memory text from results
470
+ memories = []
471
+ if isinstance(results, list):
472
+ for item in results:
473
+ if isinstance(item, dict):
474
+ text = item.get("memory", item.get("text", item.get("content", "")))
475
+ else:
476
+ text = str(item)
477
+ if text:
478
+ memories.append(text)
479
+
480
+ return memories
481
+ except Exception as e:
482
+ logger.warning(f"Failed to get memory context: {e}")
483
+ return []
484
+
485
+ async def get_rag_context():
486
+ """Fetch relevant historical data from RAG."""
487
+ try:
488
+ rag_service = get_rag_service()
489
+
490
+ # Query for historical health patterns
491
+ results = await rag_service.query(
492
+ user_id=user_id,
493
+ query="health trends patterns previous recommendations lab results",
494
+ k=5
495
+ )
496
+
497
+ # Extract relevant snippets
498
+ snippets = []
499
+ if results:
500
+ for doc in results:
501
+ if isinstance(doc, dict):
502
+ content = doc.get("content", doc.get("text", ""))
503
+ if content:
504
+ # Truncate long snippets
505
+ snippets.append(content[:500] if len(content) > 500 else content)
506
+
507
+ return snippets
508
+ except Exception as e:
509
+ logger.warning(f"Failed to get RAG context: {e}")
510
+ return []
511
+
512
+ async def get_graph_context():
513
+ """Fetch medical relationships from Neo4j/Graphiti."""
514
+ try:
515
+ graph_service = get_graph_service()
516
+ if graph_service.client is None:
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
+ )
524
+
525
+ return results if isinstance(results, list) else []
526
+ except Exception as e:
527
+ logger.warning(f"Failed to get graph context: {e}")
528
+ return []
529
+
530
+ # Execute all queries in parallel for performance
531
+ try:
532
+ memory_results, rag_results, graph_results = await asyncio.gather(
533
+ get_memory_context(),
534
+ get_rag_context(),
535
+ get_graph_context(),
536
+ return_exceptions=True
537
+ )
538
+
539
+ # Handle any exceptions from gather
540
+ if isinstance(memory_results, Exception):
541
+ logger.warning(f"Memory query failed: {memory_results}")
542
+ memory_results = []
543
+ if isinstance(rag_results, Exception):
544
+ logger.warning(f"RAG query failed: {rag_results}")
545
+ rag_results = []
546
+ if isinstance(graph_results, Exception):
547
+ logger.warning(f"Graph query failed: {graph_results}")
548
+ graph_results = []
549
+
550
+ # Add to context
551
+ context["user_preferences"] = memory_results
552
+ context["historical_context"] = rag_results
553
+ context["medical_relationships"] = graph_results
554
+
555
+ logger.info(
556
+ f"Context enriched with {len(memory_results)} memories, "
557
+ f"{len(rag_results)} RAG docs, {len(graph_results)} graph facts"
558
+ )
559
+
560
+ except Exception as e:
561
+ logger.error(f"Failed to gather memory/knowledge context: {e}")
562
+ context["user_preferences"] = []
563
+ context["historical_context"] = []
564
+ context["medical_relationships"] = []
565
+
566
  return context
567
 
568
 
backend/tests/test_routes_graph.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Graph API Routes
3
+
4
+ Tests the /api/graph endpoints for knowledge graph queries.
5
+ """
6
+ import pytest
7
+ from unittest.mock import MagicMock, patch, AsyncMock
8
+
9
+
10
+ class TestGraphRoutes:
11
+ """Test suite for graph API routes."""
12
+
13
+ @pytest.fixture
14
+ def mock_graph_service(self):
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",
22
+ ])
23
+ return mock_service
24
+
25
+ @pytest.fixture
26
+ def mock_graph_service_unavailable(self):
27
+ """Create a mock graph service that's unavailable."""
28
+ mock_service = MagicMock()
29
+ mock_service.client = None # Service unavailable
30
+ return mock_service
31
+
32
+ @pytest.fixture
33
+ def mock_user(self):
34
+ """Create a mock user."""
35
+ user = MagicMock()
36
+ user.id = "test-user-123"
37
+ return user
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_get_user_graph_facts_success(self, mock_graph_service, mock_user):
41
+ """Test successful retrieval of graph facts."""
42
+ with patch('app.routes.graph.get_graph_service', return_value=mock_graph_service):
43
+ from app.routes.graph import get_user_graph_facts
44
+
45
+ result = await get_user_graph_facts(
46
+ query="health conditions",
47
+ limit=20,
48
+ current_user=mock_user
49
+ )
50
+
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):
58
+ """Test handling when graph service is unavailable."""
59
+ with patch('app.routes.graph.get_graph_service', return_value=mock_graph_service_unavailable):
60
+ from app.routes.graph import get_user_graph_facts
61
+
62
+ result = await get_user_graph_facts(
63
+ query="health",
64
+ limit=20,
65
+ current_user=mock_user
66
+ )
67
+
68
+ assert result.available is False
69
+ assert result.count == 0
70
+ assert len(result.facts) == 0
71
+ assert "not available" in result.message.lower()
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_search_graph_success(self, mock_graph_service, mock_user):
75
+ """Test successful graph search."""
76
+ with patch('app.routes.graph.get_graph_service', return_value=mock_graph_service):
77
+ from app.routes.graph import search_graph
78
+ from app.schemas_memory import GraphSearchRequest
79
+
80
+ request = GraphSearchRequest(query="cardiovascular", limit=10)
81
+ result = await search_graph(request=request, current_user=mock_user)
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
+ )
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_get_graph_visualization_data_success(self, mock_graph_service, mock_user):
92
+ """Test successful retrieval of graph visualization data."""
93
+ with patch('app.routes.graph.get_graph_service', return_value=mock_graph_service):
94
+ from app.routes.graph import get_graph_visualization_data
95
+
96
+ result = await get_graph_visualization_data(
97
+ query="health",
98
+ limit=30,
99
+ current_user=mock_user
100
+ )
101
+
102
+ assert result.available is True
103
+ assert result.total_relationships == 3
104
+ assert result.total_nodes > 0 # Should have created nodes from relationships
105
+ assert len(result.nodes) > 0
106
+ assert len(result.relationships) == 3
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_relationship_parsing(self, mock_graph_service, mock_user):
110
+ """Test that relationships are correctly parsed from search results."""
111
+ with patch('app.routes.graph.get_graph_service', return_value=mock_graph_service):
112
+ from app.routes.graph import get_user_graph_facts
113
+
114
+ result = await get_user_graph_facts(
115
+ query="test",
116
+ limit=10,
117
+ current_user=mock_user
118
+ )
119
+
120
+ # Check first relationship is parsed correctly
121
+ rel = result.facts[0]
122
+ assert rel.source == "High LDL"
123
+ assert rel.relation == "leads_to"
124
+ assert rel.target == "Cardiovascular Risk"
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_graph_error_handling(self, mock_user):
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
135
+
136
+ result = await get_user_graph_facts(
137
+ query="test",
138
+ limit=10,
139
+ current_user=mock_user
140
+ )
141
+
142
+ # Should return empty list with error message, not raise
143
+ assert result.count == 0
144
+ assert "error" in result.message.lower()
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_node_type_inference(self, mock_graph_service, mock_user):
148
+ """Test that node types are correctly inferred."""
149
+ with patch('app.routes.graph.get_graph_service', return_value=mock_graph_service):
150
+ from app.routes.graph import get_graph_visualization_data
151
+
152
+ result = await get_graph_visualization_data(
153
+ query="test",
154
+ limit=10,
155
+ current_user=mock_user
156
+ )
157
+
158
+ # Check that nodes have types
159
+ node_types = {node.type for node in result.nodes}
160
+ # Should have at least some type variety
161
+ assert len(node_types) >= 1
backend/tests/test_routes_memory.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Memory API Routes
3
+
4
+ Tests the /api/memory endpoints for CRUD operations on user memories.
5
+ """
6
+ import pytest
7
+ from unittest.mock import MagicMock, patch, AsyncMock
8
+
9
+
10
+ class TestMemoryRoutes:
11
+ """Test suite for memory API routes."""
12
+
13
+ @pytest.fixture
14
+ def mock_memory_service(self):
15
+ """Create a mock memory service."""
16
+ mock_service = MagicMock()
17
+ mock_service.is_available = True
18
+ mock_service.get_all = AsyncMock(return_value=[
19
+ {"id": "mem-1", "memory": "User prefers vegetarian diet", "created_at": None, "metadata": {}},
20
+ {"id": "mem-2", "memory": "User exercises in the morning", "created_at": None, "metadata": {}},
21
+ ])
22
+ mock_service.search = AsyncMock(return_value=[
23
+ {"id": "mem-1", "memory": "User prefers vegetarian diet", "created_at": None, "metadata": {}},
24
+ ])
25
+ mock_service.add = AsyncMock(return_value={"id": "mem-new", "memory": "New preference"})
26
+ mock_service.delete = AsyncMock(return_value=True)
27
+ mock_service.delete_all = AsyncMock(return_value=True)
28
+ return mock_service
29
+
30
+ @pytest.fixture
31
+ def mock_user(self):
32
+ """Create a mock user."""
33
+ user = MagicMock()
34
+ user.id = "test-user-123"
35
+ return user
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_get_user_memories_success(self, mock_memory_service, mock_user):
39
+ """Test successful retrieval of user memories."""
40
+ with patch('app.routes.memory.get_memory_service', return_value=mock_memory_service):
41
+ from app.routes.memory import get_user_memories
42
+
43
+ result = await get_user_memories(current_user=mock_user)
44
+
45
+ assert result.available is True
46
+ assert result.total_count == 2
47
+ assert len(result.facts) == 2
48
+ mock_memory_service.get_all.assert_called_once_with(user_id="test-user-123")
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_get_user_memories_service_unavailable(self, mock_user):
52
+ """Test handling when memory service is unavailable."""
53
+ mock_service = MagicMock()
54
+ mock_service.is_available = False
55
+
56
+ with patch('app.routes.memory.get_memory_service', return_value=mock_service):
57
+ from app.routes.memory import get_user_memories
58
+
59
+ result = await get_user_memories(current_user=mock_user)
60
+
61
+ assert result.available is False
62
+ assert result.total_count == 0
63
+ assert len(result.facts) == 0
64
+ assert "not available" in result.message.lower()
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_search_memories_success(self, mock_memory_service, mock_user):
68
+ """Test successful memory search."""
69
+ with patch('app.routes.memory.get_memory_service', return_value=mock_memory_service):
70
+ from app.routes.memory import search_memories
71
+ from app.schemas_memory import MemorySearchRequest
72
+
73
+ request = MemorySearchRequest(query="diet", limit=5)
74
+ result = await search_memories(request=request, current_user=mock_user)
75
+
76
+ assert result.available is True
77
+ assert result.total_count == 1
78
+ mock_memory_service.search.assert_called_once_with(
79
+ query="diet",
80
+ user_id="test-user-123",
81
+ limit=5
82
+ )
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_create_memory_success(self, mock_memory_service, mock_user):
86
+ """Test successful memory creation."""
87
+ with patch('app.routes.memory.get_memory_service', return_value=mock_memory_service):
88
+ from app.routes.memory import create_memory
89
+ from app.schemas_memory import MemoryCreateRequest
90
+
91
+ request = MemoryCreateRequest(content="I prefer low sodium diet")
92
+ result = await create_memory(request=request, current_user=mock_user)
93
+
94
+ assert result.success is True
95
+ mock_memory_service.add.assert_called_once()
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_delete_memory_success(self, mock_memory_service, mock_user):
99
+ """Test successful memory deletion."""
100
+ with patch('app.routes.memory.get_memory_service', return_value=mock_memory_service):
101
+ from app.routes.memory import delete_memory
102
+
103
+ result = await delete_memory(memory_id="mem-1", current_user=mock_user)
104
+
105
+ assert result.success is True
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."""
112
+ with patch('app.routes.memory.get_memory_service', return_value=mock_memory_service):
113
+ from app.routes.memory import delete_all_memories
114
+
115
+ result = await delete_all_memories(current_user=mock_user)
116
+
117
+ assert result.success is True
118
+ mock_memory_service.delete_all.assert_called_once_with(user_id="test-user-123")
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_memory_error_handling(self, mock_user):
122
+ """Test error handling in memory routes."""
123
+ mock_service = MagicMock()
124
+ mock_service.is_available = True
125
+ mock_service.get_all = AsyncMock(side_effect=Exception("Database error"))
126
+
127
+ with patch('app.routes.memory.get_memory_service', return_value=mock_service):
128
+ from app.routes.memory import get_user_memories
129
+
130
+ result = await get_user_memories(current_user=mock_user)
131
+
132
+ # Should return empty list with error message, not raise
133
+ assert result.total_count == 0
134
+ assert "error" in result.message.lower()
frontend/src/App.tsx CHANGED
@@ -10,6 +10,7 @@ import Settings from './pages/Settings'
10
  import Recommendations from './pages/Recommendations'
11
  import Medicines from './pages/Medicines'
12
  import VoiceAgent from './pages/VoiceAgent'
 
13
  import ProtectedRoute from './components/ProtectedRoute'
14
  import './App.css'
15
 
@@ -20,69 +21,77 @@ function App() {
20
  <Route path="/" element={<HomePage />} />
21
  <Route path="/login" element={<Login />} />
22
  <Route path="/signup" element={<Signup />} />
23
- <Route
24
- path="/dashboard"
25
  element={
26
  <ProtectedRoute>
27
  <Dashboard />
28
  </ProtectedRoute>
29
- }
30
  />
31
- <Route
32
- path="/reports"
33
  element={
34
  <ProtectedRoute>
35
  <Reports />
36
  </ProtectedRoute>
37
- }
38
  />
39
- <Route
40
- path="/report-summary"
41
  element={
42
  <ProtectedRoute>
43
  <ReportSummary />
44
  </ProtectedRoute>
45
- }
46
  />
47
- <Route
48
- path="/recommendations"
49
  element={
50
  <ProtectedRoute>
51
  <Recommendations />
52
  </ProtectedRoute>
53
- }
54
  />
55
- <Route
56
- path="/medicines"
57
  element={
58
  <ProtectedRoute>
59
  <Medicines />
60
  </ProtectedRoute>
61
- }
62
  />
63
- <Route
64
- path="/voice-agent"
65
  element={
66
  <ProtectedRoute>
67
  <VoiceAgent />
68
  </ProtectedRoute>
69
- }
70
  />
71
- <Route
72
- path="/health-profile"
73
  element={
74
  <ProtectedRoute>
75
  <HealthProfile />
76
  </ProtectedRoute>
77
- }
78
  />
79
- <Route
80
- path="/settings"
 
 
 
 
 
 
 
 
81
  element={
82
  <ProtectedRoute>
83
  <Settings />
84
  </ProtectedRoute>
85
- }
86
  />
87
  {/* Catch-all: redirect unknown routes to home */}
88
  <Route path="*" element={<Navigate to="/" replace />} />
 
10
  import Recommendations from './pages/Recommendations'
11
  import Medicines from './pages/Medicines'
12
  import VoiceAgent from './pages/VoiceAgent'
13
+ import Features from './pages/Features'
14
  import ProtectedRoute from './components/ProtectedRoute'
15
  import './App.css'
16
 
 
21
  <Route path="/" element={<HomePage />} />
22
  <Route path="/login" element={<Login />} />
23
  <Route path="/signup" element={<Signup />} />
24
+ <Route
25
+ path="/dashboard"
26
  element={
27
  <ProtectedRoute>
28
  <Dashboard />
29
  </ProtectedRoute>
30
+ }
31
  />
32
+ <Route
33
+ path="/reports"
34
  element={
35
  <ProtectedRoute>
36
  <Reports />
37
  </ProtectedRoute>
38
+ }
39
  />
40
+ <Route
41
+ path="/report-summary"
42
  element={
43
  <ProtectedRoute>
44
  <ReportSummary />
45
  </ProtectedRoute>
46
+ }
47
  />
48
+ <Route
49
+ path="/recommendations"
50
  element={
51
  <ProtectedRoute>
52
  <Recommendations />
53
  </ProtectedRoute>
54
+ }
55
  />
56
+ <Route
57
+ path="/medicines"
58
  element={
59
  <ProtectedRoute>
60
  <Medicines />
61
  </ProtectedRoute>
62
+ }
63
  />
64
+ <Route
65
+ path="/voice-agent"
66
  element={
67
  <ProtectedRoute>
68
  <VoiceAgent />
69
  </ProtectedRoute>
70
+ }
71
  />
72
+ <Route
73
+ path="/health-profile"
74
  element={
75
  <ProtectedRoute>
76
  <HealthProfile />
77
  </ProtectedRoute>
78
+ }
79
  />
80
+ <Route
81
+ path="/features"
82
+ element={
83
+ <ProtectedRoute>
84
+ <Features />
85
+ </ProtectedRoute>
86
+ }
87
+ />
88
+ <Route
89
+ path="/settings"
90
  element={
91
  <ProtectedRoute>
92
  <Settings />
93
  </ProtectedRoute>
94
+ }
95
  />
96
  {/* Catch-all: redirect unknown routes to home */}
97
  <Route path="*" element={<Navigate to="/" replace />} />
frontend/src/components/HealthGraph.css ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ Health Graph Visualization Styles
3
+ Uses dashboard design tokens for consistent theming
4
+ ============================================================================ */
5
+
6
+ .health-graph {
7
+ background: var(--dash-surface, #faf8f4);
8
+ border-radius: var(--dash-radius-xl, 1.5rem);
9
+ margin-top: var(--dash-spacing-lg, 1.5rem);
10
+ box-shadow: var(--dash-shadow-sm, 0 2px 8px rgba(107, 145, 117, 0.08));
11
+ overflow: hidden;
12
+ }
13
+
14
+ /* Header */
15
+ .health-graph-header {
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: space-between;
19
+ padding: var(--dash-spacing-lg, 1.5rem) var(--dash-spacing-xl, 2rem);
20
+ cursor: pointer;
21
+ transition: background var(--dash-transition-base, 250ms);
22
+ user-select: none;
23
+ }
24
+
25
+ .health-graph-header:hover {
26
+ background: var(--dash-surface-hover, #ffffff);
27
+ }
28
+
29
+ .health-graph-header-left {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: var(--dash-spacing-md, 1rem);
33
+ }
34
+
35
+ .health-graph-icon {
36
+ color: var(--dash-accent, #6b9175);
37
+ }
38
+
39
+ .health-graph-header h3 {
40
+ font-size: 1.25rem;
41
+ font-weight: 600;
42
+ color: var(--dash-text, #2d2d2d);
43
+ margin: 0;
44
+ font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
45
+ }
46
+
47
+ .health-graph-badge {
48
+ display: inline-flex;
49
+ align-items: center;
50
+ gap: 0.35rem;
51
+ padding: 0.25rem 0.75rem;
52
+ background: var(--dash-accent-pale, #e8f3eb);
53
+ color: var(--dash-accent-dark, #4a7c59);
54
+ border-radius: var(--dash-radius-full, 9999px);
55
+ font-size: 0.8125rem;
56
+ font-weight: 600;
57
+ }
58
+
59
+ .health-graph-header-right {
60
+ color: var(--dash-text-muted, #999999);
61
+ }
62
+
63
+ /* Content */
64
+ .health-graph-content {
65
+ overflow: hidden;
66
+ border-top: 1px solid var(--dash-border-light, #f0ebe3);
67
+ }
68
+
69
+ /* Toolbar */
70
+ .health-graph-toolbar {
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: space-between;
74
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
75
+ background: var(--dash-surface-hover, #ffffff);
76
+ }
77
+
78
+ .health-graph-controls {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: var(--dash-spacing-xs, 0.5rem);
82
+ background: var(--dash-surface, #faf8f4);
83
+ border: 1px solid var(--dash-border, #e5dfd5);
84
+ border-radius: var(--dash-radius-md, 0.75rem);
85
+ padding: 0.25rem;
86
+ }
87
+
88
+ .health-graph-controls button {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ width: 32px;
93
+ height: 32px;
94
+ border: none;
95
+ background: transparent;
96
+ color: var(--dash-text-secondary, #6b6b6b);
97
+ border-radius: var(--dash-radius-sm, 0.5rem);
98
+ cursor: pointer;
99
+ transition: all var(--dash-transition-fast, 150ms);
100
+ }
101
+
102
+ .health-graph-controls button:hover {
103
+ background: var(--dash-accent-pale, #e8f3eb);
104
+ color: var(--dash-accent-dark, #4a7c59);
105
+ }
106
+
107
+ .zoom-level {
108
+ font-size: 0.8125rem;
109
+ color: var(--dash-text-secondary, #6b6b6b);
110
+ min-width: 44px;
111
+ text-align: center;
112
+ font-weight: 500;
113
+ }
114
+
115
+ .health-graph-refresh {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ width: 36px;
120
+ height: 36px;
121
+ border: none;
122
+ background: var(--dash-accent-pale, #e8f3eb);
123
+ color: var(--dash-accent-dark, #4a7c59);
124
+ border-radius: var(--dash-radius-md, 0.75rem);
125
+ cursor: pointer;
126
+ transition: all var(--dash-transition-base, 250ms);
127
+ border: 1px solid var(--dash-accent-light, #8fb199);
128
+ }
129
+
130
+ .health-graph-refresh:hover {
131
+ background: var(--dash-accent-light, #8fb199);
132
+ color: white;
133
+ }
134
+
135
+ /* States */
136
+ .health-graph-loading,
137
+ .health-graph-empty,
138
+ .health-graph-unavailable {
139
+ display: flex;
140
+ flex-direction: column;
141
+ align-items: center;
142
+ justify-content: center;
143
+ padding: var(--dash-spacing-2xl, 3rem);
144
+ text-align: center;
145
+ color: var(--dash-text-secondary, #6b6b6b);
146
+ }
147
+
148
+ .health-graph-loading svg,
149
+ .health-graph-empty svg,
150
+ .health-graph-unavailable svg {
151
+ color: var(--dash-accent, #6b9175);
152
+ opacity: 0.5;
153
+ }
154
+
155
+ .health-graph-loading p,
156
+ .health-graph-empty p,
157
+ .health-graph-unavailable p {
158
+ margin: var(--dash-spacing-md, 1rem) 0 var(--dash-spacing-xs, 0.5rem);
159
+ font-weight: 600;
160
+ color: var(--dash-text, #2d2d2d);
161
+ font-size: 1.1rem;
162
+ }
163
+
164
+ .health-graph-empty span,
165
+ .health-graph-unavailable span {
166
+ font-size: 0.9rem;
167
+ max-width: 320px;
168
+ line-height: 1.5;
169
+ }
170
+
171
+ .health-graph-error {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: var(--dash-spacing-sm, 0.75rem);
175
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
176
+ background: rgba(200, 90, 84, 0.1);
177
+ color: var(--dash-danger, #c85a54);
178
+ font-size: 0.875rem;
179
+ }
180
+
181
+ /* Canvas */
182
+ .health-graph-canvas-wrapper {
183
+ position: relative;
184
+ padding: var(--dash-spacing-lg, 1.5rem);
185
+ background: white;
186
+ margin: 0 var(--dash-spacing-xl, 2rem) var(--dash-spacing-lg, 1.5rem);
187
+ border-radius: var(--dash-radius-lg, 1rem);
188
+ border: 1px solid var(--dash-border, #e5dfd5);
189
+ overflow: hidden;
190
+ }
191
+
192
+ .health-graph-canvas {
193
+ width: 100%;
194
+ height: 400px;
195
+ transition: transform var(--dash-transition-fast, 150ms);
196
+ transform-origin: center center;
197
+ }
198
+
199
+ /* SVG Styles */
200
+ .relationship-line {
201
+ stroke: var(--dash-border, #e5dfd5);
202
+ stroke-width: 2;
203
+ fill: none;
204
+ }
205
+
206
+ .relationship-label {
207
+ font-size: 10px;
208
+ fill: var(--dash-text-muted, #999999);
209
+ text-anchor: middle;
210
+ pointer-events: none;
211
+ font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
212
+ }
213
+
214
+ .node {
215
+ cursor: pointer;
216
+ transition: transform var(--dash-transition-fast, 150ms);
217
+ }
218
+
219
+ .node:hover {
220
+ transform: scale(1.1);
221
+ }
222
+
223
+ .node-circle {
224
+ stroke: white;
225
+ stroke-width: 3;
226
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
227
+ transition: all var(--dash-transition-fast, 150ms);
228
+ }
229
+
230
+ .node:hover .node-circle {
231
+ filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
232
+ }
233
+
234
+ .node-label {
235
+ font-size: 10px;
236
+ fill: white;
237
+ font-weight: 600;
238
+ pointer-events: none;
239
+ font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
240
+ }
241
+
242
+ /* Tooltip */
243
+ .health-graph-tooltip {
244
+ position: absolute;
245
+ top: var(--dash-spacing-md, 1rem);
246
+ right: var(--dash-spacing-md, 1rem);
247
+ background: var(--dash-text, #2d2d2d);
248
+ color: white;
249
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-lg, 1.5rem);
250
+ border-radius: var(--dash-radius-md, 0.75rem);
251
+ font-size: 0.875rem;
252
+ box-shadow: var(--dash-shadow-md, 0 4px 16px rgba(107, 145, 117, 0.12));
253
+ z-index: 10;
254
+ }
255
+
256
+ .health-graph-tooltip strong {
257
+ display: block;
258
+ margin-bottom: 0.25rem;
259
+ }
260
+
261
+ .tooltip-type {
262
+ display: block;
263
+ font-size: 0.75rem;
264
+ color: var(--dash-text-muted, #999999);
265
+ text-transform: capitalize;
266
+ }
267
+
268
+ /* Legend */
269
+ .health-graph-legend {
270
+ display: flex;
271
+ flex-wrap: wrap;
272
+ gap: var(--dash-spacing-md, 1rem);
273
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
274
+ background: var(--dash-surface-hover, #ffffff);
275
+ justify-content: center;
276
+ }
277
+
278
+ .legend-item {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: var(--dash-spacing-xs, 0.5rem);
282
+ }
283
+
284
+ .legend-color {
285
+ width: 12px;
286
+ height: 12px;
287
+ border-radius: var(--dash-radius-full, 9999px);
288
+ flex-shrink: 0;
289
+ border: 2px solid white;
290
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
291
+ }
292
+
293
+ .legend-label {
294
+ font-size: 0.8125rem;
295
+ color: var(--dash-text-secondary, #6b6b6b);
296
+ text-transform: capitalize;
297
+ }
298
+
299
+ /* Footer */
300
+ .health-graph-footer {
301
+ display: flex;
302
+ align-items: center;
303
+ gap: var(--dash-spacing-sm, 0.75rem);
304
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
305
+ background: var(--dash-accent-pale, #e8f3eb);
306
+ font-size: 0.85rem;
307
+ color: var(--dash-accent-dark, #4a7c59);
308
+ }
309
+
310
+ /* Spinner animation */
311
+ .spinning {
312
+ animation: spin 1s linear infinite;
313
+ }
314
+
315
+ @keyframes spin {
316
+ to {
317
+ transform: rotate(360deg);
318
+ }
319
+ }
320
+
321
+ /* Responsive */
322
+ @media (max-width: 768px) {
323
+ .health-graph-header {
324
+ padding: var(--dash-spacing-md, 1rem);
325
+ }
326
+
327
+ .health-graph-toolbar {
328
+ padding: var(--dash-spacing-md, 1rem);
329
+ flex-wrap: wrap;
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
+ }
336
+
337
+ .health-graph-canvas {
338
+ height: 300px;
339
+ }
340
+
341
+ .health-graph-legend {
342
+ padding: var(--dash-spacing-sm, 0.75rem) var(--dash-spacing-md, 1rem);
343
+ gap: var(--dash-spacing-sm, 0.75rem);
344
+ }
345
+ }
frontend/src/components/HealthGraph.tsx ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import {
4
+ Network,
5
+ RefreshCw,
6
+ ChevronDown,
7
+ ChevronUp,
8
+ AlertCircle,
9
+ Sparkles,
10
+ ZoomIn,
11
+ ZoomOut,
12
+ Maximize2,
13
+ Info,
14
+ } from 'lucide-react';
15
+ import { API_BASE_URL } from '../config/api';
16
+ import './HealthGraph.css';
17
+
18
+ // Types matching backend schemas
19
+ interface GraphNode {
20
+ id: string;
21
+ name: string;
22
+ type: string;
23
+ properties: Record<string, unknown>;
24
+ }
25
+
26
+ interface GraphRelationship {
27
+ source: string;
28
+ relation: string;
29
+ target: string;
30
+ properties: Record<string, unknown>;
31
+ }
32
+
33
+ interface GraphDataResponse {
34
+ nodes: GraphNode[];
35
+ relationships: GraphRelationship[];
36
+ total_nodes: number;
37
+ total_relationships: number;
38
+ available: boolean;
39
+ message: string | null;
40
+ }
41
+
42
+ interface HealthGraphProps {
43
+ authToken?: string;
44
+ apiBaseUrl?: string;
45
+ defaultCollapsed?: boolean;
46
+ }
47
+
48
+ // Node type colors - using dashboard theme palette
49
+ const nodeColors: Record<string, string> = {
50
+ condition: '#c85a54', // dash-danger (red)
51
+ metric: '#7a9cc6', // dash-info (blue)
52
+ medication: '#6b9175', // dash-accent (green)
53
+ recommendation: '#d9a962', // dash-warning (amber)
54
+ user: '#4a7c59', // dash-accent-dark (dark green)
55
+ entity: '#999999', // dash-text-muted (gray)
56
+ };
57
+
58
+ const HealthGraph: React.FC<HealthGraphProps> = ({
59
+ authToken,
60
+ apiBaseUrl = API_BASE_URL,
61
+ defaultCollapsed = true,
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');
75
+ return;
76
+ }
77
+
78
+ setLoading(true);
79
+ setError(null);
80
+
81
+ try {
82
+ const response = await fetch(`${apiBaseUrl}/api/graph/relationships?limit=30`, {
83
+ headers: {
84
+ Authorization: `Bearer ${authToken}`,
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ });
88
+
89
+ if (!response.ok) {
90
+ throw new Error(`Failed to fetch graph data: ${response.status}`);
91
+ }
92
+
93
+ const data: GraphDataResponse = await response.json();
94
+ setGraphData(data);
95
+ setAvailable(data.available);
96
+ if (data.message) {
97
+ setError(data.message);
98
+ }
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : 'Failed to load graph data');
101
+ } finally {
102
+ setLoading(false);
103
+ }
104
+ }, [authToken, apiBaseUrl]);
105
+
106
+ useEffect(() => {
107
+ if (!collapsed && authToken) {
108
+ fetchGraphData();
109
+ }
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;
117
+ const centerY = height / 2;
118
+
119
+ // Position nodes in a circle or hierarchical layout
120
+ const nodePositions: Record<string, { x: number; y: number }> = {};
121
+ const nodeCount = nodes.length;
122
+
123
+ if (nodeCount === 0) return nodePositions;
124
+
125
+ // Group nodes by type
126
+ const nodesByType: Record<string, GraphNode[]> = {};
127
+ nodes.forEach(node => {
128
+ if (!nodesByType[node.type]) {
129
+ nodesByType[node.type] = [];
130
+ }
131
+ nodesByType[node.type].push(node);
132
+ });
133
+
134
+ // Position nodes by type in concentric circles
135
+ const types = Object.keys(nodesByType);
136
+ let currentRadius = 80;
137
+
138
+ types.forEach((type, typeIndex) => {
139
+ const typeNodes = nodesByType[type];
140
+ const angleStep = (2 * Math.PI) / typeNodes.length;
141
+ const offsetAngle = (typeIndex * Math.PI) / types.length;
142
+
143
+ typeNodes.forEach((node, nodeIndex) => {
144
+ const angle = offsetAngle + nodeIndex * angleStep;
145
+ nodePositions[node.id] = {
146
+ x: centerX + currentRadius * Math.cos(angle),
147
+ y: centerY + currentRadius * Math.sin(angle),
148
+ };
149
+ });
150
+
151
+ currentRadius += 60;
152
+ });
153
+
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));
163
+ const handleResetZoom = () => setZoom(1);
164
+
165
+ return (
166
+ <div className="health-graph">
167
+ <div
168
+ className="health-graph-header"
169
+ onClick={() => setCollapsed(!collapsed)}
170
+ role="button"
171
+ tabIndex={0}
172
+ onKeyDown={(e) => e.key === 'Enter' && setCollapsed(!collapsed)}
173
+ >
174
+ <div className="health-graph-header-left">
175
+ <Network size={20} className="health-graph-icon" />
176
+ <h3>Health Knowledge Graph</h3>
177
+ <span className="health-graph-badge">
178
+ {available ? (
179
+ <>{graphData?.total_nodes || 0} nodes</>
180
+ ) : (
181
+ <><AlertCircle size={12} /> Offline</>
182
+ )}
183
+ </span>
184
+ </div>
185
+ <div className="health-graph-header-right">
186
+ {collapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
187
+ </div>
188
+ </div>
189
+
190
+ <AnimatePresence>
191
+ {!collapsed && (
192
+ <motion.div
193
+ initial={{ height: 0, opacity: 0 }}
194
+ animate={{ height: 'auto', opacity: 1 }}
195
+ exit={{ height: 0, opacity: 0 }}
196
+ transition={{ duration: 0.2 }}
197
+ className="health-graph-content"
198
+ >
199
+ {!available ? (
200
+ <div className="health-graph-unavailable">
201
+ <AlertCircle size={32} />
202
+ <p>Knowledge graph service is not available</p>
203
+ <span>Neo4j connection is required for relationship visualization.</span>
204
+ </div>
205
+ ) : loading ? (
206
+ <div className="health-graph-loading">
207
+ <RefreshCw size={24} className="spinning" />
208
+ <p>Loading health relationships...</p>
209
+ </div>
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
+ <>
218
+ <div className="health-graph-toolbar">
219
+ <div className="health-graph-controls">
220
+ <button onClick={handleZoomOut} title="Zoom out">
221
+ <ZoomOut size={16} />
222
+ </button>
223
+ <span className="zoom-level">{Math.round(zoom * 100)}%</span>
224
+ <button onClick={handleZoomIn} title="Zoom in">
225
+ <ZoomIn size={16} />
226
+ </button>
227
+ <button onClick={handleResetZoom} title="Reset zoom">
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 && (
241
+ <div className="health-graph-error">
242
+ <AlertCircle size={16} />
243
+ <span>{error}</span>
244
+ </div>
245
+ )}
246
+
247
+ <div className="health-graph-canvas-wrapper">
248
+ <svg
249
+ ref={svgRef}
250
+ className="health-graph-canvas"
251
+ viewBox="0 0 600 400"
252
+ style={{ transform: `scale(${zoom})` }}
253
+ >
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
+
262
+ return (
263
+ <g key={`rel-${index}`} className="relationship">
264
+ <line
265
+ x1={sourcePos.x}
266
+ y1={sourcePos.y}
267
+ x2={targetPos.x}
268
+ y2={targetPos.y}
269
+ className="relationship-line"
270
+ />
271
+ <text
272
+ x={(sourcePos.x + targetPos.x) / 2}
273
+ y={(sourcePos.y + targetPos.y) / 2 - 5}
274
+ className="relationship-label"
275
+ >
276
+ {rel.relation}
277
+ </text>
278
+ </g>
279
+ );
280
+ })}
281
+ </g>
282
+
283
+ {/* Nodes */}
284
+ <g className="nodes">
285
+ {graphData.nodes.map((node) => {
286
+ const pos = nodePositions[node.id];
287
+ if (!pos) return null;
288
+
289
+ const isHovered = hoveredNode?.id === node.id;
290
+
291
+ return (
292
+ <g
293
+ key={node.id}
294
+ className="node"
295
+ transform={`translate(${pos.x}, ${pos.y})`}
296
+ onMouseEnter={() => setHoveredNode(node)}
297
+ onMouseLeave={() => setHoveredNode(null)}
298
+ >
299
+ <circle
300
+ r={isHovered ? 28 : 24}
301
+ fill={nodeColors[node.type] || nodeColors.entity}
302
+ className="node-circle"
303
+ />
304
+ <text
305
+ y={5}
306
+ textAnchor="middle"
307
+ className="node-label"
308
+ >
309
+ {node.name.length > 12
310
+ ? node.name.slice(0, 10) + '...'
311
+ : node.name}
312
+ </text>
313
+ </g>
314
+ );
315
+ })}
316
+ </g>
317
+ </svg>
318
+
319
+ {/* Tooltip */}
320
+ {hoveredNode && (
321
+ <div className="health-graph-tooltip">
322
+ <strong>{hoveredNode.name}</strong>
323
+ <span className="tooltip-type">{hoveredNode.type}</span>
324
+ </div>
325
+ )}
326
+ </div>
327
+
328
+ {/* Legend */}
329
+ <div className="health-graph-legend">
330
+ {Object.entries(nodeColors).slice(0, 5).map(([type, color]) => (
331
+ <div key={type} className="legend-item">
332
+ <span
333
+ className="legend-color"
334
+ style={{ backgroundColor: color }}
335
+ />
336
+ <span className="legend-label">{type}</span>
337
+ </div>
338
+ ))}
339
+ </div>
340
+
341
+ <div className="health-graph-footer">
342
+ <Info size={14} />
343
+ <span>
344
+ Showing {graphData.total_nodes} entities and {graphData.total_relationships} relationships
345
+ </span>
346
+ </div>
347
+ </>
348
+ )}
349
+ </motion.div>
350
+ )}
351
+ </AnimatePresence>
352
+ </div>
353
+ );
354
+ };
355
+
356
+ export default HealthGraph;
frontend/src/components/MemoryDashboard.css ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ Memory Dashboard Styles
3
+ Uses dashboard design tokens for consistent theming
4
+ ============================================================================ */
5
+
6
+ .memory-dashboard {
7
+ background: var(--dash-surface, #faf8f4);
8
+ border-radius: var(--dash-radius-xl, 1.5rem);
9
+ margin-top: var(--dash-spacing-lg, 1.5rem);
10
+ box-shadow: var(--dash-shadow-sm, 0 2px 8px rgba(107, 145, 117, 0.08));
11
+ overflow: hidden;
12
+ }
13
+
14
+ /* Header - Clickable to expand/collapse */
15
+ .memory-header {
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: space-between;
19
+ padding: var(--dash-spacing-lg, 1.5rem) var(--dash-spacing-xl, 2rem);
20
+ cursor: pointer;
21
+ transition: background var(--dash-transition-base, 250ms);
22
+ user-select: none;
23
+ border-bottom: 1px solid transparent;
24
+ }
25
+
26
+ .memory-header:hover {
27
+ background: var(--dash-surface-hover, #ffffff);
28
+ }
29
+
30
+ .memory-header-left {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: var(--dash-spacing-md, 1rem);
34
+ }
35
+
36
+ .memory-icon {
37
+ color: var(--dash-accent, #6b9175);
38
+ }
39
+
40
+ .memory-header h3 {
41
+ font-size: 1.25rem;
42
+ font-weight: 600;
43
+ color: var(--dash-text, #2d2d2d);
44
+ margin: 0;
45
+ font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
46
+ }
47
+
48
+ .memory-badge {
49
+ display: inline-flex;
50
+ align-items: center;
51
+ gap: 0.35rem;
52
+ padding: 0.25rem 0.75rem;
53
+ background: var(--dash-accent-pale, #e8f3eb);
54
+ color: var(--dash-accent-dark, #4a7c59);
55
+ border-radius: var(--dash-radius-full, 9999px);
56
+ font-size: 0.8125rem;
57
+ font-weight: 600;
58
+ }
59
+
60
+ .memory-header-right {
61
+ color: var(--dash-text-muted, #999999);
62
+ }
63
+
64
+ /* Content Area */
65
+ .memory-content {
66
+ overflow: hidden;
67
+ border-top: 1px solid var(--dash-border-light, #f0ebe3);
68
+ }
69
+
70
+ /* Toolbar */
71
+ .memory-toolbar {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
76
+ gap: var(--dash-spacing-md, 1rem);
77
+ background: var(--dash-surface-hover, #ffffff);
78
+ }
79
+
80
+ .memory-search {
81
+ display: flex;
82
+ align-items: center;
83
+ flex: 1;
84
+ gap: var(--dash-spacing-sm, 0.75rem);
85
+ background: var(--dash-surface, #faf8f4);
86
+ border: 1px solid var(--dash-border, #e5dfd5);
87
+ border-radius: var(--dash-radius-md, 0.75rem);
88
+ padding: 0.5rem 0.875rem;
89
+ transition: all var(--dash-transition-fast, 150ms);
90
+ }
91
+
92
+ .memory-search:focus-within {
93
+ border-color: var(--dash-accent, #6b9175);
94
+ box-shadow: 0 0 0 3px var(--dash-accent-pale, #e8f3eb);
95
+ }
96
+
97
+ .memory-search svg {
98
+ color: var(--dash-text-muted, #999999);
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ .memory-search input {
103
+ flex: 1;
104
+ border: none;
105
+ outline: none;
106
+ font-size: 0.9rem;
107
+ color: var(--dash-text, #2d2d2d);
108
+ background: transparent;
109
+ font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
110
+ }
111
+
112
+ .memory-search input::placeholder {
113
+ color: var(--dash-text-muted, #999999);
114
+ }
115
+
116
+ .memory-search-clear {
117
+ background: none;
118
+ border: none;
119
+ padding: 0.2rem;
120
+ cursor: pointer;
121
+ color: var(--dash-text-muted, #999999);
122
+ display: flex;
123
+ align-items: center;
124
+ border-radius: var(--dash-radius-sm, 0.5rem);
125
+ transition: all var(--dash-transition-fast, 150ms);
126
+ }
127
+
128
+ .memory-search-clear:hover {
129
+ color: var(--dash-text-secondary, #6b6b6b);
130
+ background: var(--dash-border-light, #f0ebe3);
131
+ }
132
+
133
+ .memory-actions {
134
+ display: flex;
135
+ gap: var(--dash-spacing-sm, 0.75rem);
136
+ }
137
+
138
+ .memory-btn {
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ gap: 0.35rem;
143
+ padding: 0.625rem 1rem;
144
+ border-radius: var(--dash-radius-md, 0.75rem);
145
+ font-size: 0.875rem;
146
+ font-weight: 500;
147
+ border: none;
148
+ cursor: pointer;
149
+ transition: all var(--dash-transition-base, 250ms);
150
+ font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
151
+ }
152
+
153
+ .memory-btn-add {
154
+ background: var(--dash-accent, #6b9175);
155
+ color: white;
156
+ box-shadow: 0 2px 4px rgba(107, 145, 117, 0.2);
157
+ }
158
+
159
+ .memory-btn-add:hover {
160
+ background: var(--dash-accent-dark, #4a7c59);
161
+ transform: translateY(-1px);
162
+ box-shadow: 0 4px 8px rgba(107, 145, 117, 0.25);
163
+ }
164
+
165
+ .memory-btn-refresh {
166
+ background: var(--dash-accent-pale, #e8f3eb);
167
+ color: var(--dash-accent-dark, #4a7c59);
168
+ border: 1px solid var(--dash-accent-light, #8fb199);
169
+ }
170
+
171
+ .memory-btn-refresh:hover:not(:disabled) {
172
+ background: var(--dash-accent-light, #8fb199);
173
+ color: white;
174
+ }
175
+
176
+ .memory-btn-refresh:disabled {
177
+ opacity: 0.5;
178
+ cursor: not-allowed;
179
+ }
180
+
181
+ /* States */
182
+ .memory-loading,
183
+ .memory-empty,
184
+ .memory-unavailable {
185
+ display: flex;
186
+ flex-direction: column;
187
+ align-items: center;
188
+ justify-content: center;
189
+ padding: var(--dash-spacing-2xl, 3rem);
190
+ text-align: center;
191
+ color: var(--dash-text-secondary, #6b6b6b);
192
+ }
193
+
194
+ .memory-loading svg,
195
+ .memory-empty svg,
196
+ .memory-unavailable svg {
197
+ color: var(--dash-accent, #6b9175);
198
+ opacity: 0.5;
199
+ }
200
+
201
+ .memory-loading p,
202
+ .memory-empty p,
203
+ .memory-unavailable p {
204
+ margin: var(--dash-spacing-md, 1rem) 0 var(--dash-spacing-xs, 0.5rem);
205
+ font-weight: 600;
206
+ color: var(--dash-text, #2d2d2d);
207
+ font-size: 1.1rem;
208
+ }
209
+
210
+ .memory-empty span,
211
+ .memory-unavailable span {
212
+ font-size: 0.9rem;
213
+ max-width: 320px;
214
+ line-height: 1.5;
215
+ }
216
+
217
+ .memory-error {
218
+ display: flex;
219
+ align-items: center;
220
+ gap: var(--dash-spacing-sm, 0.75rem);
221
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
222
+ background: rgba(200, 90, 84, 0.1);
223
+ color: var(--dash-danger, #c85a54);
224
+ font-size: 0.875rem;
225
+ }
226
+
227
+ .memory-error svg {
228
+ flex-shrink: 0;
229
+ }
230
+
231
+ /* Memory List */
232
+ .memory-list {
233
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem) var(--dash-spacing-xl, 2rem);
234
+ max-height: 350px;
235
+ overflow-y: auto;
236
+ }
237
+
238
+ .memory-item {
239
+ display: flex;
240
+ align-items: flex-start;
241
+ justify-content: space-between;
242
+ gap: var(--dash-spacing-md, 1rem);
243
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-lg, 1.5rem);
244
+ background: white;
245
+ border: 1px solid var(--dash-border, #e5dfd5);
246
+ border-radius: var(--dash-radius-lg, 1rem);
247
+ margin-bottom: var(--dash-spacing-sm, 0.75rem);
248
+ transition: all var(--dash-transition-base, 250ms);
249
+ }
250
+
251
+ .memory-item:hover {
252
+ border-color: var(--dash-accent-light, #8fb199);
253
+ box-shadow: var(--dash-shadow-sm, 0 2px 8px rgba(107, 145, 117, 0.08));
254
+ transform: translateY(-1px);
255
+ }
256
+
257
+ .memory-item-content {
258
+ flex: 1;
259
+ }
260
+
261
+ .memory-item-content p {
262
+ margin: 0;
263
+ font-size: 0.9375rem;
264
+ color: var(--dash-text, #2d2d2d);
265
+ line-height: 1.5;
266
+ }
267
+
268
+ .memory-date {
269
+ display: block;
270
+ font-size: 0.75rem;
271
+ color: var(--dash-text-muted, #999999);
272
+ margin-top: var(--dash-spacing-xs, 0.5rem);
273
+ }
274
+
275
+ .memory-delete-btn {
276
+ background: none;
277
+ border: none;
278
+ padding: 0.5rem;
279
+ cursor: pointer;
280
+ color: var(--dash-text-muted, #999999);
281
+ border-radius: var(--dash-radius-sm, 0.5rem);
282
+ transition: all var(--dash-transition-fast, 150ms);
283
+ flex-shrink: 0;
284
+ }
285
+
286
+ .memory-delete-btn:hover {
287
+ background: rgba(200, 90, 84, 0.1);
288
+ color: var(--dash-danger, #c85a54);
289
+ }
290
+
291
+ .memory-delete-btn:disabled {
292
+ opacity: 0.5;
293
+ cursor: not-allowed;
294
+ }
295
+
296
+ /* Footer */
297
+ .memory-footer {
298
+ display: flex;
299
+ align-items: center;
300
+ gap: var(--dash-spacing-sm, 0.75rem);
301
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem);
302
+ background: var(--dash-accent-pale, #e8f3eb);
303
+ font-size: 0.85rem;
304
+ color: var(--dash-accent-dark, #4a7c59);
305
+ }
306
+
307
+ .memory-footer svg {
308
+ flex-shrink: 0;
309
+ }
310
+
311
+ /* Modal */
312
+ .memory-modal-overlay {
313
+ position: fixed;
314
+ inset: 0;
315
+ background: rgba(45, 45, 45, 0.5);
316
+ display: flex;
317
+ align-items: center;
318
+ justify-content: center;
319
+ z-index: var(--dash-z-modal, 1000);
320
+ padding: var(--dash-spacing-lg, 1.5rem);
321
+ backdrop-filter: blur(4px);
322
+ }
323
+
324
+ .memory-modal {
325
+ background: var(--dash-surface, #faf8f4);
326
+ border-radius: var(--dash-radius-xl, 1.5rem);
327
+ width: 100%;
328
+ max-width: 480px;
329
+ overflow: hidden;
330
+ box-shadow: var(--dash-shadow-lg, 0 8px 32px rgba(107, 145, 117, 0.15));
331
+ }
332
+
333
+ .memory-modal-header {
334
+ display: flex;
335
+ align-items: center;
336
+ justify-content: space-between;
337
+ padding: var(--dash-spacing-lg, 1.5rem) var(--dash-spacing-xl, 2rem);
338
+ border-bottom: 1px solid var(--dash-border-light, #f0ebe3);
339
+ }
340
+
341
+ .memory-modal-header h4 {
342
+ margin: 0;
343
+ font-size: 1.25rem;
344
+ font-weight: 600;
345
+ color: var(--dash-text, #2d2d2d);
346
+ font-family: var(--dash-font-serif, 'Playfair Display', Georgia, serif);
347
+ }
348
+
349
+ .memory-modal-header button {
350
+ background: none;
351
+ border: none;
352
+ padding: 0.375rem;
353
+ cursor: pointer;
354
+ color: var(--dash-text-muted, #999999);
355
+ border-radius: var(--dash-radius-sm, 0.5rem);
356
+ transition: all var(--dash-transition-fast, 150ms);
357
+ }
358
+
359
+ .memory-modal-header button:hover {
360
+ background: var(--dash-border-light, #f0ebe3);
361
+ color: var(--dash-text-secondary, #6b6b6b);
362
+ }
363
+
364
+ .memory-modal-body {
365
+ padding: var(--dash-spacing-xl, 2rem);
366
+ }
367
+
368
+ .memory-modal-body textarea {
369
+ width: 100%;
370
+ padding: var(--dash-spacing-md, 1rem);
371
+ border: 1px solid var(--dash-border, #e5dfd5);
372
+ border-radius: var(--dash-radius-md, 0.75rem);
373
+ font-size: 0.9375rem;
374
+ resize: none;
375
+ font-family: var(--dash-font-sans, 'DM Sans', sans-serif);
376
+ color: var(--dash-text, #2d2d2d);
377
+ background: white;
378
+ transition: all var(--dash-transition-fast, 150ms);
379
+ }
380
+
381
+ .memory-modal-body textarea:focus {
382
+ outline: none;
383
+ border-color: var(--dash-accent, #6b9175);
384
+ box-shadow: 0 0 0 3px var(--dash-accent-pale, #e8f3eb);
385
+ }
386
+
387
+ .memory-modal-body textarea::placeholder {
388
+ color: var(--dash-text-muted, #999999);
389
+ }
390
+
391
+ .memory-modal-footer {
392
+ display: flex;
393
+ justify-content: flex-end;
394
+ gap: var(--dash-spacing-md, 1rem);
395
+ padding: var(--dash-spacing-md, 1rem) var(--dash-spacing-xl, 2rem) var(--dash-spacing-xl, 2rem);
396
+ }
397
+
398
+ .memory-btn-cancel {
399
+ background: var(--dash-border-light, #f0ebe3);
400
+ color: var(--dash-text-secondary, #6b6b6b);
401
+ }
402
+
403
+ .memory-btn-cancel:hover {
404
+ background: var(--dash-border, #e5dfd5);
405
+ }
406
+
407
+ .memory-btn-save {
408
+ background: var(--dash-accent, #6b9175);
409
+ color: white;
410
+ box-shadow: 0 2px 4px rgba(107, 145, 117, 0.2);
411
+ }
412
+
413
+ .memory-btn-save:hover:not(:disabled) {
414
+ background: var(--dash-accent-dark, #4a7c59);
415
+ transform: translateY(-1px);
416
+ }
417
+
418
+ .memory-btn-save:disabled {
419
+ opacity: 0.5;
420
+ cursor: not-allowed;
421
+ }
422
+
423
+ /* Spinner animation */
424
+ .spinning {
425
+ animation: spin 1s linear infinite;
426
+ }
427
+
428
+ @keyframes spin {
429
+ to {
430
+ transform: rotate(360deg);
431
+ }
432
+ }
433
+
434
+ /* Scrollbar */
435
+ .memory-list::-webkit-scrollbar {
436
+ width: 6px;
437
+ }
438
+
439
+ .memory-list::-webkit-scrollbar-track {
440
+ background: var(--dash-border-light, #f0ebe3);
441
+ border-radius: 3px;
442
+ }
443
+
444
+ .memory-list::-webkit-scrollbar-thumb {
445
+ background: var(--dash-accent-light, #8fb199);
446
+ border-radius: 3px;
447
+ }
448
+
449
+ .memory-list::-webkit-scrollbar-thumb:hover {
450
+ background: var(--dash-accent, #6b9175);
451
+ }
452
+
453
+ /* Responsive */
454
+ @media (max-width: 768px) {
455
+ .memory-header {
456
+ padding: var(--dash-spacing-md, 1rem);
457
+ }
458
+
459
+ .memory-toolbar {
460
+ flex-direction: column;
461
+ align-items: stretch;
462
+ padding: var(--dash-spacing-md, 1rem);
463
+ }
464
+
465
+ .memory-actions {
466
+ justify-content: flex-end;
467
+ }
468
+
469
+ .memory-list {
470
+ padding: var(--dash-spacing-md, 1rem);
471
+ }
472
+
473
+ .memory-item {
474
+ padding: var(--dash-spacing-md, 1rem);
475
+ }
476
+
477
+ .memory-modal {
478
+ max-width: 100%;
479
+ margin: var(--dash-spacing-md, 1rem);
480
+ }
481
+ }
frontend/src/components/MemoryDashboard.tsx ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import {
4
+ Brain,
5
+ Trash2,
6
+ RefreshCw,
7
+ ChevronDown,
8
+ ChevronUp,
9
+ AlertCircle,
10
+ Sparkles,
11
+ Database,
12
+ Plus,
13
+ X,
14
+ Search,
15
+ Info,
16
+ } from 'lucide-react';
17
+ import { API_BASE_URL } from '../config/api';
18
+ import './MemoryDashboard.css';
19
+
20
+ // Types matching backend schemas
21
+ interface MemoryFact {
22
+ id: string;
23
+ memory: string;
24
+ created_at: string | null;
25
+ metadata: Record<string, unknown>;
26
+ }
27
+
28
+ interface MemoryListResponse {
29
+ facts: MemoryFact[];
30
+ total_count: number;
31
+ available: boolean;
32
+ message: string | null;
33
+ }
34
+
35
+ interface MemoryDashboardProps {
36
+ authToken?: string;
37
+ apiBaseUrl?: string;
38
+ defaultCollapsed?: boolean;
39
+ }
40
+
41
+ const MemoryDashboard: React.FC<MemoryDashboardProps> = ({
42
+ authToken,
43
+ apiBaseUrl = API_BASE_URL,
44
+ defaultCollapsed = true,
45
+ }) => {
46
+ const [memories, setMemories] = useState<MemoryFact[]>([]);
47
+ const [loading, setLoading] = useState(false);
48
+ const [deleting, setDeleting] = useState<string | null>(null);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
51
+ const [available, setAvailable] = useState(true);
52
+ const [showAddModal, setShowAddModal] = useState(false);
53
+ const [newMemory, setNewMemory] = useState('');
54
+ const [adding, setAdding] = useState(false);
55
+ const [searchQuery, setSearchQuery] = useState('');
56
+
57
+ const fetchMemories = useCallback(async () => {
58
+ if (!authToken) {
59
+ setError('Authentication required');
60
+ return;
61
+ }
62
+
63
+ setLoading(true);
64
+ setError(null);
65
+
66
+ try {
67
+ const response = await fetch(`${apiBaseUrl}/api/memory/facts`, {
68
+ headers: {
69
+ Authorization: `Bearer ${authToken}`,
70
+ 'Content-Type': 'application/json',
71
+ },
72
+ });
73
+
74
+ if (!response.ok) {
75
+ throw new Error(`Failed to fetch memories: ${response.status}`);
76
+ }
77
+
78
+ const data: MemoryListResponse = await response.json();
79
+ setMemories(data.facts);
80
+ setAvailable(data.available);
81
+ if (data.message) {
82
+ setError(data.message);
83
+ }
84
+ } catch (err) {
85
+ setError(err instanceof Error ? err.message : 'Failed to load memories');
86
+ } finally {
87
+ setLoading(false);
88
+ }
89
+ }, [authToken, apiBaseUrl]);
90
+
91
+ useEffect(() => {
92
+ if (!collapsed && authToken) {
93
+ fetchMemories();
94
+ }
95
+ }, [collapsed, authToken, fetchMemories]);
96
+
97
+ const handleDelete = async (memoryId: string) => {
98
+ if (!authToken) return;
99
+
100
+ setDeleting(memoryId);
101
+ try {
102
+ const response = await fetch(`${apiBaseUrl}/api/memory/facts/${memoryId}`, {
103
+ method: 'DELETE',
104
+ headers: {
105
+ Authorization: `Bearer ${authToken}`,
106
+ 'Content-Type': 'application/json',
107
+ },
108
+ });
109
+
110
+ if (response.ok) {
111
+ setMemories(prev => prev.filter(m => m.id !== memoryId));
112
+ } else {
113
+ throw new Error('Failed to delete memory');
114
+ }
115
+ } catch (err) {
116
+ setError(err instanceof Error ? err.message : 'Failed to delete memory');
117
+ } finally {
118
+ setDeleting(null);
119
+ }
120
+ };
121
+
122
+ const handleAdd = async () => {
123
+ if (!authToken || !newMemory.trim()) return;
124
+
125
+ setAdding(true);
126
+ try {
127
+ const response = await fetch(`${apiBaseUrl}/api/memory/facts`, {
128
+ method: 'POST',
129
+ headers: {
130
+ Authorization: `Bearer ${authToken}`,
131
+ 'Content-Type': 'application/json',
132
+ },
133
+ body: JSON.stringify({ content: newMemory.trim() }),
134
+ });
135
+
136
+ if (response.ok) {
137
+ setNewMemory('');
138
+ setShowAddModal(false);
139
+ await fetchMemories();
140
+ } else {
141
+ const data = await response.json();
142
+ throw new Error(data.message || 'Failed to add memory');
143
+ }
144
+ } catch (err) {
145
+ setError(err instanceof Error ? err.message : 'Failed to add memory');
146
+ } finally {
147
+ setAdding(false);
148
+ }
149
+ };
150
+
151
+ const handleSearch = async () => {
152
+ if (!authToken || !searchQuery.trim()) {
153
+ await fetchMemories();
154
+ return;
155
+ }
156
+
157
+ setLoading(true);
158
+ try {
159
+ const response = await fetch(`${apiBaseUrl}/api/memory/search`, {
160
+ method: 'POST',
161
+ headers: {
162
+ Authorization: `Bearer ${authToken}`,
163
+ 'Content-Type': 'application/json',
164
+ },
165
+ body: JSON.stringify({ query: searchQuery, limit: 20 }),
166
+ });
167
+
168
+ if (response.ok) {
169
+ const data: MemoryListResponse = await response.json();
170
+ setMemories(data.facts);
171
+ }
172
+ } catch (err) {
173
+ setError(err instanceof Error ? err.message : 'Search failed');
174
+ } finally {
175
+ setLoading(false);
176
+ }
177
+ };
178
+
179
+ const formatDate = (dateStr: string | null) => {
180
+ if (!dateStr) return '';
181
+ const date = new Date(dateStr);
182
+ return date.toLocaleDateString('en-US', {
183
+ month: 'short',
184
+ day: 'numeric',
185
+ year: 'numeric'
186
+ });
187
+ };
188
+
189
+ return (
190
+ <div className="memory-dashboard">
191
+ <div
192
+ className="memory-header"
193
+ onClick={() => setCollapsed(!collapsed)}
194
+ role="button"
195
+ tabIndex={0}
196
+ onKeyDown={(e) => e.key === 'Enter' && setCollapsed(!collapsed)}
197
+ >
198
+ <div className="memory-header-left">
199
+ <Brain size={20} className="memory-icon" />
200
+ <h3>Health Memory</h3>
201
+ <span className="memory-badge">
202
+ {available ? (
203
+ <><Database size={12} /> {memories.length}</>
204
+ ) : (
205
+ <><AlertCircle size={12} /> Offline</>
206
+ )}
207
+ </span>
208
+ </div>
209
+ <div className="memory-header-right">
210
+ {collapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
211
+ </div>
212
+ </div>
213
+
214
+ <AnimatePresence>
215
+ {!collapsed && (
216
+ <motion.div
217
+ initial={{ height: 0, opacity: 0 }}
218
+ animate={{ height: 'auto', opacity: 1 }}
219
+ exit={{ height: 0, opacity: 0 }}
220
+ transition={{ duration: 0.2 }}
221
+ className="memory-content"
222
+ >
223
+ {!available ? (
224
+ <div className="memory-unavailable">
225
+ <AlertCircle size={32} />
226
+ <p>Memory service is not available</p>
227
+ <span>Recommendations will still work, but without personalization from your preferences.</span>
228
+ </div>
229
+ ) : (
230
+ <>
231
+ <div className="memory-toolbar">
232
+ <div className="memory-search">
233
+ <Search size={16} />
234
+ <input
235
+ type="text"
236
+ placeholder="Search memories..."
237
+ value={searchQuery}
238
+ onChange={(e) => setSearchQuery(e.target.value)}
239
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
240
+ />
241
+ {searchQuery && (
242
+ <button
243
+ className="memory-search-clear"
244
+ onClick={() => { setSearchQuery(''); fetchMemories(); }}
245
+ >
246
+ <X size={14} />
247
+ </button>
248
+ )}
249
+ </div>
250
+ <div className="memory-actions">
251
+ <button
252
+ className="memory-btn memory-btn-add"
253
+ onClick={() => setShowAddModal(true)}
254
+ title="Add memory"
255
+ >
256
+ <Plus size={16} />
257
+ </button>
258
+ <button
259
+ className="memory-btn memory-btn-refresh"
260
+ onClick={fetchMemories}
261
+ disabled={loading}
262
+ title="Refresh"
263
+ >
264
+ <RefreshCw size={16} className={loading ? 'spinning' : ''} />
265
+ </button>
266
+ </div>
267
+ </div>
268
+
269
+ {error && (
270
+ <div className="memory-error">
271
+ <AlertCircle size={16} />
272
+ <span>{error}</span>
273
+ </div>
274
+ )}
275
+
276
+ {loading ? (
277
+ <div className="memory-loading">
278
+ <RefreshCw size={24} className="spinning" />
279
+ <p>Loading memories...</p>
280
+ </div>
281
+ ) : memories.length === 0 ? (
282
+ <div className="memory-empty">
283
+ <Sparkles size={32} />
284
+ <p>No memories yet</p>
285
+ <span>Your health preferences and facts will appear here as you interact with the assistant.</span>
286
+ </div>
287
+ ) : (
288
+ <div className="memory-list">
289
+ {memories.map((memory, index) => (
290
+ <motion.div
291
+ key={memory.id}
292
+ initial={{ opacity: 0, x: -10 }}
293
+ animate={{ opacity: 1, x: 0 }}
294
+ transition={{ delay: index * 0.03 }}
295
+ className="memory-item"
296
+ >
297
+ <div className="memory-item-content">
298
+ <p>{memory.memory}</p>
299
+ {memory.created_at && (
300
+ <span className="memory-date">
301
+ {formatDate(memory.created_at)}
302
+ </span>
303
+ )}
304
+ </div>
305
+ <button
306
+ className="memory-delete-btn"
307
+ onClick={() => handleDelete(memory.id)}
308
+ disabled={deleting === memory.id}
309
+ title="Delete memory"
310
+ >
311
+ {deleting === memory.id ? (
312
+ <RefreshCw size={14} className="spinning" />
313
+ ) : (
314
+ <Trash2 size={14} />
315
+ )}
316
+ </button>
317
+ </motion.div>
318
+ ))}
319
+ </div>
320
+ )}
321
+
322
+ <div className="memory-footer">
323
+ <Info size={14} />
324
+ <span>Memories help personalize your health recommendations.</span>
325
+ </div>
326
+ </>
327
+ )}
328
+ </motion.div>
329
+ )}
330
+ </AnimatePresence>
331
+
332
+ {/* Add Memory Modal */}
333
+ <AnimatePresence>
334
+ {showAddModal && (
335
+ <motion.div
336
+ className="memory-modal-overlay"
337
+ initial={{ opacity: 0 }}
338
+ animate={{ opacity: 1 }}
339
+ exit={{ opacity: 0 }}
340
+ onClick={() => setShowAddModal(false)}
341
+ >
342
+ <motion.div
343
+ className="memory-modal"
344
+ initial={{ scale: 0.9, opacity: 0 }}
345
+ animate={{ scale: 1, opacity: 1 }}
346
+ exit={{ scale: 0.9, opacity: 0 }}
347
+ onClick={(e) => e.stopPropagation()}
348
+ >
349
+ <div className="memory-modal-header">
350
+ <h4>Add a Preference</h4>
351
+ <button onClick={() => setShowAddModal(false)}>
352
+ <X size={18} />
353
+ </button>
354
+ </div>
355
+ <div className="memory-modal-body">
356
+ <textarea
357
+ placeholder="e.g., I prefer vegetarian diet, I exercise in the morning, I'm allergic to penicillin..."
358
+ value={newMemory}
359
+ onChange={(e) => setNewMemory(e.target.value)}
360
+ rows={3}
361
+ />
362
+ </div>
363
+ <div className="memory-modal-footer">
364
+ <button
365
+ className="memory-btn memory-btn-cancel"
366
+ onClick={() => setShowAddModal(false)}
367
+ >
368
+ Cancel
369
+ </button>
370
+ <button
371
+ className="memory-btn memory-btn-save"
372
+ onClick={handleAdd}
373
+ disabled={adding || !newMemory.trim()}
374
+ >
375
+ {adding ? (
376
+ <><RefreshCw size={14} className="spinning" /> Saving...</>
377
+ ) : (
378
+ <><Plus size={14} /> Add Preference</>
379
+ )}
380
+ </button>
381
+ </div>
382
+ </motion.div>
383
+ </motion.div>
384
+ )}
385
+ </AnimatePresence>
386
+ </div>
387
+ );
388
+ };
389
+
390
+ export default MemoryDashboard;
frontend/src/components/ProfileSuccessScreen.tsx CHANGED
@@ -8,6 +8,7 @@ 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 './ProfileSuccessScreen.css';
12
 
13
  interface ProfileSuccessScreenProps {
@@ -15,12 +16,36 @@ interface ProfileSuccessScreenProps {
15
  redirectDelay?: number;
16
  }
17
 
18
- export default function ProfileSuccessScreen({
19
  autoRedirect = true,
20
- redirectDelay = 2000
21
  }: ProfileSuccessScreenProps) {
22
  const navigate = useNavigate();
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  useEffect(() => {
25
  if (autoRedirect) {
26
  const timer = setTimeout(() => {
@@ -40,17 +65,17 @@ export default function ProfileSuccessScreen({
40
  };
41
 
42
  return (
43
- <motion.div
44
  className="profile-success-overlay"
45
  initial={{ opacity: 0 }}
46
  animate={{ opacity: 1 }}
47
  exit={{ opacity: 0 }}
48
  >
49
- <motion.div
50
  className="profile-success-container"
51
  initial={{ scale: 0.8, opacity: 0 }}
52
  animate={{ scale: 1, opacity: 1 }}
53
- transition={{
54
  type: "spring",
55
  stiffness: 200,
56
  damping: 20,
@@ -58,18 +83,18 @@ export default function ProfileSuccessScreen({
58
  }}
59
  >
60
  {/* Animated Checkmark */}
61
- <motion.div
62
  className="success-checkmark-container"
63
  initial={{ scale: 0 }}
64
  animate={{ scale: 1 }}
65
- transition={{
66
  type: "spring",
67
  stiffness: 300,
68
  damping: 15,
69
  delay: 0.2
70
  }}
71
  >
72
- <motion.div
73
  className="success-checkmark-circle"
74
  initial={{ scale: 0, rotate: -180 }}
75
  animate={{ scale: 1, rotate: 0 }}
@@ -83,7 +108,7 @@ export default function ProfileSuccessScreen({
83
  <Check size={48} strokeWidth={3} />
84
  </motion.div>
85
  </motion.div>
86
-
87
  {/* Ripple effect */}
88
  <motion.div
89
  className="success-ripple"
@@ -113,14 +138,14 @@ export default function ProfileSuccessScreen({
113
  animate={{ opacity: 1, y: 0 }}
114
  transition={{ delay: 0.8, duration: 0.4 }}
115
  >
116
- <button
117
  className="success-btn success-btn-primary"
118
  onClick={handleDashboard}
119
  >
120
  Go to Dashboard
121
  <ArrowRight size={18} />
122
  </button>
123
- <button
124
  className="success-btn success-btn-secondary"
125
  onClick={handleSettings}
126
  >
 
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 {
 
16
  redirectDelay?: number;
17
  }
18
 
19
+ export default function ProfileSuccessScreen({
20
  autoRedirect = true,
21
+ redirectDelay = 2000
22
  }: ProfileSuccessScreenProps) {
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(() => {
 
65
  };
66
 
67
  return (
68
+ <motion.div
69
  className="profile-success-overlay"
70
  initial={{ opacity: 0 }}
71
  animate={{ opacity: 1 }}
72
  exit={{ opacity: 0 }}
73
  >
74
+ <motion.div
75
  className="profile-success-container"
76
  initial={{ scale: 0.8, opacity: 0 }}
77
  animate={{ scale: 1, opacity: 1 }}
78
+ transition={{
79
  type: "spring",
80
  stiffness: 200,
81
  damping: 20,
 
83
  }}
84
  >
85
  {/* Animated Checkmark */}
86
+ <motion.div
87
  className="success-checkmark-container"
88
  initial={{ scale: 0 }}
89
  animate={{ scale: 1 }}
90
+ transition={{
91
  type: "spring",
92
  stiffness: 300,
93
  damping: 15,
94
  delay: 0.2
95
  }}
96
  >
97
+ <motion.div
98
  className="success-checkmark-circle"
99
  initial={{ scale: 0, rotate: -180 }}
100
  animate={{ scale: 1, rotate: 0 }}
 
108
  <Check size={48} strokeWidth={3} />
109
  </motion.div>
110
  </motion.div>
111
+
112
  {/* Ripple effect */}
113
  <motion.div
114
  className="success-ripple"
 
138
  animate={{ opacity: 1, y: 0 }}
139
  transition={{ delay: 0.8, duration: 0.4 }}
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
149
  className="success-btn success-btn-secondary"
150
  onClick={handleSettings}
151
  >
frontend/src/components/RecommendationsPanel.css CHANGED
@@ -76,7 +76,9 @@
76
  }
77
 
78
  @keyframes spin {
79
- to { transform: rotate(360deg); }
 
 
80
  }
81
 
82
  .recommendations-badges {
@@ -383,7 +385,9 @@
383
  }
384
 
385
  @keyframes spin {
386
- to { transform: rotate(360deg); }
 
 
387
  }
388
 
389
  .recommendations-loading p {
@@ -488,17 +492,32 @@
488
  opacity: 0;
489
  transform: translateY(10px);
490
  }
 
491
  to {
492
  opacity: 1;
493
  transform: translateY(0);
494
  }
495
  }
496
 
497
- .recommendation-card:nth-child(1) { animation-delay: 0.05s; }
498
- .recommendation-card:nth-child(2) { animation-delay: 0.1s; }
499
- .recommendation-card:nth-child(3) { animation-delay: 0.15s; }
500
- .recommendation-card:nth-child(4) { animation-delay: 0.2s; }
501
- .recommendation-card:nth-child(5) { animation-delay: 0.25s; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
 
503
  /* Responsive */
504
  @media (max-width: 768px) {
@@ -524,3 +543,113 @@
524
  width: fit-content;
525
  }
526
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  }
77
 
78
  @keyframes spin {
79
+ to {
80
+ transform: rotate(360deg);
81
+ }
82
  }
83
 
84
  .recommendations-badges {
 
385
  }
386
 
387
  @keyframes spin {
388
+ to {
389
+ transform: rotate(360deg);
390
+ }
391
  }
392
 
393
  .recommendations-loading p {
 
492
  opacity: 0;
493
  transform: translateY(10px);
494
  }
495
+
496
  to {
497
  opacity: 1;
498
  transform: translateY(0);
499
  }
500
  }
501
 
502
+ .recommendation-card:nth-child(1) {
503
+ animation-delay: 0.05s;
504
+ }
505
+
506
+ .recommendation-card:nth-child(2) {
507
+ animation-delay: 0.1s;
508
+ }
509
+
510
+ .recommendation-card:nth-child(3) {
511
+ animation-delay: 0.15s;
512
+ }
513
+
514
+ .recommendation-card:nth-child(4) {
515
+ animation-delay: 0.2s;
516
+ }
517
+
518
+ .recommendation-card:nth-child(5) {
519
+ animation-delay: 0.25s;
520
+ }
521
 
522
  /* Responsive */
523
  @media (max-width: 768px) {
 
543
  width: fit-content;
544
  }
545
  }
546
+
547
+ /* Recommendation Header Right */
548
+ .recommendation-header-right {
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 0.5rem;
552
+ flex-shrink: 0;
553
+ }
554
+
555
+ /* Provenance Tooltip Styles */
556
+ .provenance-badge {
557
+ position: relative;
558
+ display: inline-flex;
559
+ align-items: center;
560
+ }
561
+
562
+ .provenance-trigger {
563
+ display: flex;
564
+ align-items: center;
565
+ gap: 0.25rem;
566
+ padding: 0.25rem 0.625rem;
567
+ background: linear-gradient(135deg, rgba(107, 145, 117, 0.15), rgba(139, 177, 153, 0.1));
568
+ border: 1px solid rgba(107, 145, 117, 0.25);
569
+ border-radius: var(--dash-radius-full, 9999px);
570
+ font-size: 0.6875rem;
571
+ font-weight: 600;
572
+ color: var(--dash-accent-dark, #4a7c59);
573
+ cursor: pointer;
574
+ transition: all 0.2s ease;
575
+ white-space: nowrap;
576
+ }
577
+
578
+ .provenance-trigger:hover {
579
+ background: linear-gradient(135deg, rgba(107, 145, 117, 0.25), rgba(139, 177, 153, 0.2));
580
+ transform: translateY(-1px);
581
+ box-shadow: 0 2px 8px rgba(107, 145, 117, 0.2);
582
+ }
583
+
584
+ .provenance-tooltip {
585
+ position: absolute;
586
+ top: calc(100% + 8px);
587
+ right: 0;
588
+ min-width: 220px;
589
+ padding: 0.75rem;
590
+ background: white;
591
+ border-radius: var(--dash-radius-md, 0.75rem);
592
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
593
+ opacity: 0;
594
+ visibility: hidden;
595
+ transform: translateY(-4px);
596
+ transition: all 0.2s ease;
597
+ z-index: 100;
598
+ }
599
+
600
+ .provenance-badge:hover .provenance-tooltip {
601
+ opacity: 1;
602
+ visibility: visible;
603
+ transform: translateY(0);
604
+ }
605
+
606
+ .provenance-title {
607
+ display: block;
608
+ font-size: 0.6875rem;
609
+ font-weight: 700;
610
+ text-transform: uppercase;
611
+ letter-spacing: 0.5px;
612
+ color: var(--dash-text-muted, #999);
613
+ margin-bottom: 0.5rem;
614
+ padding-bottom: 0.5rem;
615
+ border-bottom: 1px solid #f0f0f0;
616
+ }
617
+
618
+ .provenance-item {
619
+ display: flex;
620
+ align-items: center;
621
+ gap: 0.5rem;
622
+ padding: 0.375rem 0;
623
+ }
624
+
625
+ .provenance-icon {
626
+ font-size: 1rem;
627
+ width: 1.5rem;
628
+ text-align: center;
629
+ }
630
+
631
+ .provenance-label {
632
+ font-size: 0.8125rem;
633
+ font-weight: 600;
634
+ color: var(--dash-text, #2d2d2d);
635
+ min-width: 3.5rem;
636
+ }
637
+
638
+ .provenance-desc {
639
+ font-size: 0.75rem;
640
+ color: var(--dash-text-secondary, #6b6b6b);
641
+ }
642
+
643
+ /* Add tooltip arrow */
644
+ .provenance-tooltip::before {
645
+ content: '';
646
+ position: absolute;
647
+ top: -6px;
648
+ right: 12px;
649
+ width: 12px;
650
+ height: 12px;
651
+ background: white;
652
+ transform: rotate(45deg);
653
+ border-left: 1px solid rgba(0, 0, 0, 0.05);
654
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
655
+ }
frontend/src/components/RecommendationsPanel.tsx CHANGED
@@ -35,6 +35,13 @@ interface Source {
35
  url?: string;
36
  }
37
 
 
 
 
 
 
 
 
38
  interface Recommendation {
39
  id: string;
40
  title: string;
@@ -43,6 +50,7 @@ interface Recommendation {
43
  actions: Action[];
44
  followup: Action[];
45
  sources: Source[];
 
46
  }
47
 
48
  interface RecommendationsResponse {
@@ -62,6 +70,7 @@ interface StoredItem {
62
  priority: string;
63
  actions?: string[] | { type?: string; text: string }[];
64
  evidence?: string[];
 
65
  }
66
 
67
  interface RecommendationsPanelProps {
@@ -111,9 +120,39 @@ function mapStoredToRecommendation(stored: StoredItem): Recommendation {
111
  actions,
112
  followup: [],
113
  sources: [],
 
114
  };
115
  }
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
118
  authToken,
119
  apiBaseUrl = API_BASE_URL,
@@ -288,7 +327,7 @@ const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
288
  {generating ? (
289
  <><RefreshCw size={16} className="spinning" /> Generating...</>
290
  ) : (
291
- <><Sparkles size={16} /> Generate</> )}
292
  </button>
293
  )}
294
  </div>
@@ -307,7 +346,7 @@ const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
307
  {generating ? (
308
  <><RefreshCw size={18} className="spinning" /> Generating...</>
309
  ) : (
310
- <><Sparkles size={18} /> Generate Recommendations</> )}
311
  </button>
312
  )}
313
  </div>
@@ -336,7 +375,7 @@ const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
336
  {generating ? (
337
  <><RefreshCw size={16} className="spinning" /> Generating...</>
338
  ) : (
339
- <><RefreshCw size={16} /> Refresh</> )}
340
  </button>
341
  )}
342
  </div>
@@ -375,10 +414,13 @@ const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
375
  >
376
  <div className="recommendation-header">
377
  <h3 className="recommendation-title">{item.title ?? ''}</h3>
378
- <span className={`severity-tag ${(item.severity ?? 'info').toLowerCase()}`}>
379
- {severityIcons[item.severity ?? 'INFO']}
380
- {item.severity ?? 'INFO'}
381
- </span>
 
 
 
382
  </div>
383
 
384
  <p className="recommendation-why">{item.why}</p>
 
35
  url?: string;
36
  }
37
 
38
+ interface Provenance {
39
+ memory?: boolean;
40
+ graph?: boolean;
41
+ metrics?: boolean;
42
+ profile?: boolean;
43
+ }
44
+
45
  interface Recommendation {
46
  id: string;
47
  title: string;
 
50
  actions: Action[];
51
  followup: Action[];
52
  sources: Source[];
53
+ provenance?: Provenance;
54
  }
55
 
56
  interface RecommendationsResponse {
 
70
  priority: string;
71
  actions?: string[] | { type?: string; text: string }[];
72
  evidence?: string[];
73
+ provenance?: Provenance;
74
  }
75
 
76
  interface RecommendationsPanelProps {
 
120
  actions,
121
  followup: [],
122
  sources: [],
123
+ provenance: stored.provenance,
124
  };
125
  }
126
 
127
+ // Provenance tooltip component - shows data sources
128
+ const ProvenanceTooltip: React.FC<{ provenance?: Provenance }> = ({ provenance }) => {
129
+ if (!provenance) return null;
130
+
131
+ const sources = [];
132
+ if (provenance.memory) sources.push({ label: 'Memory', icon: '🧠', desc: 'Your preferences & facts' });
133
+ if (provenance.graph) sources.push({ label: 'Graph', icon: '🕸️', desc: 'Health relationships' });
134
+ if (provenance.metrics) sources.push({ label: 'Metrics', icon: '📊', desc: 'Lab values & observations' });
135
+ if (provenance.profile) sources.push({ label: 'Profile', icon: '👤', desc: 'Your health profile' });
136
+
137
+ if (sources.length === 0) return null;
138
+
139
+ return (
140
+ <div className="provenance-badge" title="Powered by">
141
+ <span className="provenance-trigger">✨ Sources</span>
142
+ <div className="provenance-tooltip">
143
+ <span className="provenance-title">Recommendation Sources</span>
144
+ {sources.map((src, i) => (
145
+ <div key={i} className="provenance-item">
146
+ <span className="provenance-icon">{src.icon}</span>
147
+ <span className="provenance-label">{src.label}</span>
148
+ <span className="provenance-desc">{src.desc}</span>
149
+ </div>
150
+ ))}
151
+ </div>
152
+ </div>
153
+ );
154
+ };
155
+
156
  const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
157
  authToken,
158
  apiBaseUrl = API_BASE_URL,
 
327
  {generating ? (
328
  <><RefreshCw size={16} className="spinning" /> Generating...</>
329
  ) : (
330
+ <><Sparkles size={16} /> Generate</>)}
331
  </button>
332
  )}
333
  </div>
 
346
  {generating ? (
347
  <><RefreshCw size={18} className="spinning" /> Generating...</>
348
  ) : (
349
+ <><Sparkles size={18} /> Generate Recommendations</>)}
350
  </button>
351
  )}
352
  </div>
 
375
  {generating ? (
376
  <><RefreshCw size={16} className="spinning" /> Generating...</>
377
  ) : (
378
+ <><RefreshCw size={16} /> Refresh</>)}
379
  </button>
380
  )}
381
  </div>
 
414
  >
415
  <div className="recommendation-header">
416
  <h3 className="recommendation-title">{item.title ?? ''}</h3>
417
+ <div className="recommendation-header-right">
418
+ <ProvenanceTooltip provenance={item.provenance} />
419
+ <span className={`severity-tag ${(item.severity ?? 'info').toLowerCase()}`}>
420
+ {severityIcons[item.severity ?? 'INFO']}
421
+ {item.severity ?? 'INFO'}
422
+ </span>
423
+ </div>
424
  </div>
425
 
426
  <p className="recommendation-why">{item.why}</p>
frontend/src/pages/Features.css ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Features Page Styles */
2
+
3
+ .features-page {
4
+ min-height: 100vh;
5
+ background: var(--dash-bg);
6
+ position: relative;
7
+ overflow-x: hidden;
8
+ }
9
+
10
+ /* Animated Background */
11
+ .features-background {
12
+ position: fixed;
13
+ inset: 0;
14
+ z-index: 0;
15
+ overflow: hidden;
16
+ pointer-events: none;
17
+ }
18
+
19
+ .features-bg-blob {
20
+ position: absolute;
21
+ border-radius: 50%;
22
+ filter: blur(120px);
23
+ opacity: 0.4;
24
+ animation: blobFloat 20s ease-in-out infinite;
25
+ }
26
+
27
+ .features-bg-blob-1 {
28
+ width: 600px;
29
+ height: 600px;
30
+ background: var(--dash-accent);
31
+ top: -200px;
32
+ right: -200px;
33
+ animation-delay: 0s;
34
+ }
35
+
36
+ .features-bg-blob-2 {
37
+ width: 500px;
38
+ height: 500px;
39
+ background: var(--dash-info);
40
+ bottom: -150px;
41
+ left: -150px;
42
+ animation-delay: -7s;
43
+ }
44
+
45
+ .features-bg-blob-3 {
46
+ width: 400px;
47
+ height: 400px;
48
+ background: var(--dash-warning);
49
+ top: 50%;
50
+ left: 50%;
51
+ transform: translate(-50%, -50%);
52
+ animation-delay: -14s;
53
+ }
54
+
55
+ @keyframes blobFloat {
56
+
57
+ 0%,
58
+ 100% {
59
+ transform: translate(0, 0) scale(1);
60
+ }
61
+
62
+ 25% {
63
+ transform: translate(20px, -30px) scale(1.05);
64
+ }
65
+
66
+ 50% {
67
+ transform: translate(-20px, 20px) scale(0.95);
68
+ }
69
+
70
+ 75% {
71
+ transform: translate(30px, 10px) scale(1.02);
72
+ }
73
+ }
74
+
75
+ /* Content */
76
+ .features-content {
77
+ position: relative;
78
+ z-index: 1;
79
+ padding: 100px 24px 60px;
80
+ }
81
+
82
+ .features-container {
83
+ max-width: 1200px;
84
+ margin: 0 auto;
85
+ }
86
+
87
+ .features-loading {
88
+ display: flex;
89
+ flex-direction: column;
90
+ align-items: center;
91
+ justify-content: center;
92
+ height: 60vh;
93
+ gap: 16px;
94
+ color: var(--dash-text-muted);
95
+ }
96
+
97
+ /* Hero Section */
98
+ .features-hero {
99
+ text-align: center;
100
+ padding: 60px 0 80px;
101
+ }
102
+
103
+ .hero-badge {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 8px;
107
+ padding: 8px 16px;
108
+ background: rgba(106, 145, 117, 0.15);
109
+ border: 1px solid rgba(106, 145, 117, 0.3);
110
+ border-radius: 100px;
111
+ color: var(--dash-accent);
112
+ font-size: 13px;
113
+ font-weight: 500;
114
+ margin-bottom: 24px;
115
+ }
116
+
117
+ .features-hero h1 {
118
+ font-size: 3.5rem;
119
+ font-weight: 700;
120
+ color: var(--dash-text);
121
+ margin: 0 0 20px;
122
+ line-height: 1.1;
123
+ }
124
+
125
+ .gradient-text {
126
+ background: linear-gradient(135deg, var(--dash-accent), var(--dash-info));
127
+ -webkit-background-clip: text;
128
+ -webkit-text-fill-color: transparent;
129
+ background-clip: text;
130
+ }
131
+
132
+ .hero-description {
133
+ font-size: 1.15rem;
134
+ color: var(--dash-text-muted);
135
+ max-width: 640px;
136
+ margin: 0 auto 48px;
137
+ line-height: 1.7;
138
+ }
139
+
140
+ /* Hero Features Grid */
141
+ .hero-features {
142
+ display: grid;
143
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
144
+ gap: 24px;
145
+ max-width: 960px;
146
+ margin: 0 auto 48px;
147
+ text-align: left;
148
+ }
149
+
150
+ .hero-feature {
151
+ display: flex;
152
+ gap: 16px;
153
+ padding: 20px;
154
+ background: var(--dash-card-bg);
155
+ border: 1px solid var(--dash-border);
156
+ border-radius: 16px;
157
+ transition: all 0.2s ease;
158
+ }
159
+
160
+ .hero-feature:hover {
161
+ transform: translateY(-2px);
162
+ border-color: var(--dash-accent);
163
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
164
+ }
165
+
166
+ .hero-feature-icon {
167
+ width: 44px;
168
+ height: 44px;
169
+ border-radius: 12px;
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ flex-shrink: 0;
174
+ }
175
+
176
+ .hero-feature-icon.memory {
177
+ background: rgba(156, 136, 255, 0.15);
178
+ color: #9c88ff;
179
+ }
180
+
181
+ .hero-feature-icon.graph {
182
+ background: rgba(122, 156, 198, 0.15);
183
+ color: var(--dash-info);
184
+ }
185
+
186
+ .hero-feature-icon.ai {
187
+ background: rgba(106, 145, 117, 0.15);
188
+ color: var(--dash-accent);
189
+ }
190
+
191
+ .hero-feature-content h4 {
192
+ margin: 0 0 6px;
193
+ font-size: 15px;
194
+ font-weight: 600;
195
+ color: var(--dash-text);
196
+ }
197
+
198
+ .hero-feature-content p {
199
+ margin: 0;
200
+ font-size: 13px;
201
+ color: var(--dash-text-muted);
202
+ line-height: 1.5;
203
+ }
204
+
205
+ .scroll-indicator {
206
+ color: var(--dash-text-muted);
207
+ opacity: 0.5;
208
+ }
209
+
210
+ /* Flow Diagram */
211
+ .features-flow {
212
+ padding: 48px 0 64px;
213
+ text-align: center;
214
+ }
215
+
216
+ .features-flow h2 {
217
+ font-size: 1.5rem;
218
+ color: var(--dash-text);
219
+ margin: 0 0 32px;
220
+ }
221
+
222
+ .flow-diagram {
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ gap: 16px;
227
+ flex-wrap: wrap;
228
+ }
229
+
230
+ .flow-step {
231
+ display: flex;
232
+ flex-direction: column;
233
+ align-items: center;
234
+ gap: 12px;
235
+ }
236
+
237
+ .flow-icon {
238
+ width: 64px;
239
+ height: 64px;
240
+ border-radius: 16px;
241
+ background: var(--dash-card-bg);
242
+ border: 2px solid var(--dash-border);
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: center;
246
+ color: var(--dash-text);
247
+ transition: all 0.2s ease;
248
+ }
249
+
250
+ .flow-icon.memory {
251
+ border-color: #9c88ff;
252
+ color: #9c88ff;
253
+ }
254
+
255
+ .flow-icon.graph {
256
+ border-color: var(--dash-info);
257
+ color: var(--dash-info);
258
+ }
259
+
260
+ .flow-icon.ai {
261
+ border-color: var(--dash-accent);
262
+ color: var(--dash-accent);
263
+ }
264
+
265
+ .flow-step span {
266
+ font-size: 13px;
267
+ color: var(--dash-text-muted);
268
+ font-weight: 500;
269
+ }
270
+
271
+ .flow-arrow {
272
+ color: var(--dash-text-muted);
273
+ opacity: 0.4;
274
+ }
275
+
276
+ /* Sections */
277
+ .features-section {
278
+ margin-bottom: 48px;
279
+ }
280
+
281
+ .section-header {
282
+ display: flex;
283
+ align-items: center;
284
+ justify-content: space-between;
285
+ margin-bottom: 20px;
286
+ flex-wrap: wrap;
287
+ gap: 16px;
288
+ }
289
+
290
+ .section-header-left {
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 16px;
294
+ }
295
+
296
+ .section-icon {
297
+ width: 44px;
298
+ height: 44px;
299
+ padding: 10px;
300
+ border-radius: 12px;
301
+ flex-shrink: 0;
302
+ }
303
+
304
+ .section-icon.memory {
305
+ background: rgba(156, 136, 255, 0.15);
306
+ color: #9c88ff;
307
+ }
308
+
309
+ .section-icon.graph {
310
+ background: rgba(122, 156, 198, 0.15);
311
+ color: var(--dash-info);
312
+ }
313
+
314
+ .section-header h2 {
315
+ margin: 0;
316
+ font-size: 1.35rem;
317
+ color: var(--dash-text);
318
+ }
319
+
320
+ .section-header p {
321
+ margin: 4px 0 0;
322
+ font-size: 14px;
323
+ color: var(--dash-text-muted);
324
+ }
325
+
326
+ .section-badge {
327
+ display: inline-flex;
328
+ align-items: center;
329
+ gap: 6px;
330
+ padding: 6px 12px;
331
+ background: var(--dash-card-bg);
332
+ border: 1px solid var(--dash-border);
333
+ border-radius: 100px;
334
+ font-size: 12px;
335
+ color: var(--dash-text-muted);
336
+ }
337
+
338
+ .section-content {
339
+ background: var(--dash-card-bg);
340
+ border: 1px solid var(--dash-border);
341
+ border-radius: 20px;
342
+ padding: 4px;
343
+ overflow: hidden;
344
+ }
345
+
346
+ .section-content-large {
347
+ min-height: 500px;
348
+ }
349
+
350
+ /* Override Memory/Graph components when on Features page */
351
+ .section-content .memory-dashboard,
352
+ .section-content .health-graph {
353
+ background: transparent;
354
+ border: none;
355
+ border-radius: 0;
356
+ }
357
+
358
+ .section-content .memory-header,
359
+ .section-content .health-graph-header {
360
+ cursor: default;
361
+ background: rgba(255, 255, 255, 0.02);
362
+ border-radius: 16px 16px 0 0;
363
+ padding: 16px 20px;
364
+ }
365
+
366
+ /* CTA Section */
367
+ .features-cta {
368
+ text-align: center;
369
+ padding: 60px 0;
370
+ margin-top: 32px;
371
+ }
372
+
373
+ .features-cta h3 {
374
+ font-size: 1.75rem;
375
+ color: var(--dash-text);
376
+ margin: 0 0 12px;
377
+ }
378
+
379
+ .features-cta p {
380
+ color: var(--dash-text-muted);
381
+ margin: 0 0 28px;
382
+ }
383
+
384
+ .cta-button {
385
+ display: inline-flex;
386
+ align-items: center;
387
+ gap: 10px;
388
+ padding: 14px 28px;
389
+ background: linear-gradient(135deg, var(--dash-accent), var(--dash-accent-dark));
390
+ color: white;
391
+ border: none;
392
+ border-radius: 12px;
393
+ font-size: 15px;
394
+ font-weight: 600;
395
+ cursor: pointer;
396
+ transition: all 0.2s ease;
397
+ }
398
+
399
+ .cta-button:hover {
400
+ transform: translateY(-2px);
401
+ box-shadow: 0 8px 24px rgba(106, 145, 117, 0.3);
402
+ }
403
+
404
+ /* Responsive */
405
+ @media (max-width: 768px) {
406
+ .features-hero h1 {
407
+ font-size: 2.25rem;
408
+ }
409
+
410
+ .hero-features {
411
+ grid-template-columns: 1fr;
412
+ }
413
+
414
+ .flow-diagram {
415
+ flex-direction: column;
416
+ }
417
+
418
+ .flow-arrow {
419
+ transform: rotate(90deg);
420
+ }
421
+
422
+ .section-header {
423
+ flex-direction: column;
424
+ align-items: flex-start;
425
+ }
426
+ }
frontend/src/pages/Features.tsx ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import {
4
+ Brain,
5
+ Network,
6
+ Sparkles,
7
+ ArrowRight,
8
+ ChevronDown,
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';
18
+ import { API_BASE_URL } from '../config/api';
19
+ import '../styles/dashboardTokens.css';
20
+ import '../styles/dashboardBase.css';
21
+ 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) {
32
+ navigate('/login');
33
+ return;
34
+ }
35
+ setAuthToken(token);
36
+
37
+ // Fetch user info
38
+ fetch(`${API_BASE_URL}/api/me/bootstrap`, {
39
+ headers: { Authorization: `Bearer ${token}` },
40
+ })
41
+ .then(res => res.json())
42
+ .then(data => {
43
+ setUserName(data.full_name?.split(' ')[0] || 'User');
44
+ })
45
+ .catch(console.error)
46
+ .finally(() => setLoading(false));
47
+ }, [navigate]);
48
+
49
+ if (loading) {
50
+ return (
51
+ <div className="features-page">
52
+ <DashboardNavbar userName="Loading..." userStatus="" />
53
+ <div className="features-loading">
54
+ <div className="loading-spinner" />
55
+ <p>Loading features...</p>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div className="features-page">
63
+ {/* Background */}
64
+ <div className="features-background">
65
+ <div className="features-bg-blob features-bg-blob-1" />
66
+ <div className="features-bg-blob features-bg-blob-2" />
67
+ <div className="features-bg-blob features-bg-blob-3" />
68
+ </div>
69
+
70
+ <DashboardNavbar userName={userName} userStatus="" />
71
+
72
+ <div className="features-content">
73
+ <div className="features-container">
74
+
75
+ {/* Hero Section */}
76
+ <motion.section
77
+ className="features-hero"
78
+ initial={{ opacity: 0, y: 20 }}
79
+ animate={{ opacity: 1, y: 0 }}
80
+ transition={{ duration: 0.6 }}
81
+ >
82
+ <div className="hero-badge">
83
+ <Sparkles size={14} />
84
+ <span>Intelligent Health Engine</span>
85
+ </div>
86
+
87
+ <h1>
88
+ <span className="gradient-text">Breathable</span> Recommendations
89
+ </h1>
90
+
91
+ <p className="hero-description">
92
+ Your health insights are powered by advanced memory and knowledge graph systems.
93
+ Every recommendation is personalized, contextual, and traceable to its source.
94
+ </p>
95
+
96
+ <div className="hero-features">
97
+ <div className="hero-feature">
98
+ <div className="hero-feature-icon memory">
99
+ <Brain size={20} />
100
+ </div>
101
+ <div className="hero-feature-content">
102
+ <h4>Mem0 Memory Layer</h4>
103
+ <p>Stores your preferences, facts, and health context for personalized care</p>
104
+ </div>
105
+ </div>
106
+
107
+ <div className="hero-feature">
108
+ <div className="hero-feature-icon graph">
109
+ <Network size={20} />
110
+ </div>
111
+ <div className="hero-feature-content">
112
+ <h4>Neo4j Knowledge Graph</h4>
113
+ <p>Maps relationships between conditions, medications, and health factors</p>
114
+ </div>
115
+ </div>
116
+
117
+ <div className="hero-feature">
118
+ <div className="hero-feature-icon ai">
119
+ <Cpu size={20} />
120
+ </div>
121
+ <div className="hero-feature-content">
122
+ <h4>Groq LLM Integration</h4>
123
+ <p>Generates evidence-based recommendations from your complete health profile</p>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <motion.div
129
+ className="scroll-indicator"
130
+ animate={{ y: [0, 8, 0] }}
131
+ transition={{ repeat: Infinity, duration: 1.5 }}
132
+ >
133
+ <ChevronDown size={24} />
134
+ </motion.div>
135
+ </motion.section>
136
+
137
+ {/* How It Works */}
138
+ <motion.section
139
+ className="features-flow"
140
+ initial={{ opacity: 0 }}
141
+ whileInView={{ opacity: 1 }}
142
+ viewport={{ once: true }}
143
+ >
144
+ <h2>How Your Data Flows</h2>
145
+ <div className="flow-diagram">
146
+ <div className="flow-step">
147
+ <div className="flow-icon">
148
+ <Database size={24} />
149
+ </div>
150
+ <span>Profile & Reports</span>
151
+ </div>
152
+ <ArrowRight className="flow-arrow" />
153
+ <div className="flow-step">
154
+ <div className="flow-icon memory">
155
+ <Brain size={24} />
156
+ </div>
157
+ <span>Memory Layer</span>
158
+ </div>
159
+ <ArrowRight className="flow-arrow" />
160
+ <div className="flow-step">
161
+ <div className="flow-icon graph">
162
+ <Network size={24} />
163
+ </div>
164
+ <span>Knowledge Graph</span>
165
+ </div>
166
+ <ArrowRight className="flow-arrow" />
167
+ <div className="flow-step">
168
+ <div className="flow-icon ai">
169
+ <Zap size={24} />
170
+ </div>
171
+ <span>AI Recommendations</span>
172
+ </div>
173
+ </div>
174
+ </motion.section>
175
+
176
+ {/* Memory Dashboard Section */}
177
+ <motion.section
178
+ className="features-section"
179
+ initial={{ opacity: 0, y: 30 }}
180
+ whileInView={{ opacity: 1, y: 0 }}
181
+ viewport={{ once: true }}
182
+ transition={{ duration: 0.5 }}
183
+ >
184
+ <div className="section-header">
185
+ <div className="section-header-left">
186
+ <Brain size={24} className="section-icon memory" />
187
+ <div>
188
+ <h2>Health Memory</h2>
189
+ <p>Your preferences and facts stored for personalized recommendations</p>
190
+ </div>
191
+ </div>
192
+ <div className="section-badge">
193
+ <Database size={14} />
194
+ <span>Powered by Mem0</span>
195
+ </div>
196
+ </div>
197
+
198
+ <div className="section-content">
199
+ <MemoryDashboard
200
+ authToken={authToken || undefined}
201
+ apiBaseUrl={API_BASE_URL}
202
+ defaultCollapsed={false}
203
+ />
204
+ </div>
205
+ </motion.section>
206
+
207
+ {/* Health Graph Section */}
208
+ <motion.section
209
+ className="features-section"
210
+ initial={{ opacity: 0, y: 30 }}
211
+ whileInView={{ opacity: 1, y: 0 }}
212
+ viewport={{ once: true }}
213
+ transition={{ duration: 0.5 }}
214
+ >
215
+ <div className="section-header">
216
+ <div className="section-header-left">
217
+ <Network size={24} className="section-icon graph" />
218
+ <div>
219
+ <h2>Health Knowledge Graph</h2>
220
+ <p>Visualize relationships between your health data</p>
221
+ </div>
222
+ </div>
223
+ <div className="section-badge">
224
+ <Link2 size={14} />
225
+ <span>Powered by Neo4j</span>
226
+ </div>
227
+ </div>
228
+
229
+ <div className="section-content section-content-large">
230
+ <HealthGraph
231
+ authToken={authToken || undefined}
232
+ apiBaseUrl={API_BASE_URL}
233
+ defaultCollapsed={false}
234
+ />
235
+ </div>
236
+ </motion.section>
237
+
238
+ {/* CTA Section */}
239
+ <motion.section
240
+ className="features-cta"
241
+ initial={{ opacity: 0 }}
242
+ whileInView={{ opacity: 1 }}
243
+ viewport={{ once: true }}
244
+ >
245
+ <h3>See your personalized recommendations</h3>
246
+ <p>Your health insights are built from all these connected systems</p>
247
+ <button
248
+ className="cta-button"
249
+ onClick={() => navigate('/dashboard')}
250
+ >
251
+ <Sparkles size={18} />
252
+ Go to Dashboard
253
+ </button>
254
+ </motion.section>
255
+
256
+ </div>
257
+ </div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ export default Features;
frontend/src/pages/Login.tsx CHANGED
@@ -20,6 +20,19 @@ function Login() {
20
  setError('')
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
24
  e.preventDefault()
25
  setError('')
@@ -30,8 +43,7 @@ function Login() {
30
  localStorage.setItem('access_token', data.access_token)
31
  navigate('/dashboard')
32
  } catch (err: unknown) {
33
- const axiosError = err as { response?: { data?: { detail?: string } } }
34
- setError(axiosError.response?.data?.detail || 'Login failed. Please check your credentials.')
35
  } finally {
36
  setLoading(false)
37
  }
 
20
  setError('')
21
  }
22
 
23
+ // Parse error response - handles both string and Pydantic v2 validation error format
24
+ const parseErrorMessage = (err: unknown): string => {
25
+ const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string; loc?: string[] }> } } }
26
+ const detail = axiosError.response?.data?.detail
27
+
28
+ if (!detail) return 'Login failed. Please check your credentials.'
29
+ if (typeof detail === 'string') return detail
30
+ if (Array.isArray(detail)) {
31
+ return detail.map(e => e.msg || 'Validation error').join('. ') || 'Validation failed.'
32
+ }
33
+ return 'Login failed. Please check your credentials.'
34
+ }
35
+
36
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
37
  e.preventDefault()
38
  setError('')
 
43
  localStorage.setItem('access_token', data.access_token)
44
  navigate('/dashboard')
45
  } catch (err: unknown) {
46
+ setError(parseErrorMessage(err))
 
47
  } finally {
48
  setLoading(false)
49
  }
frontend/src/pages/Signup.tsx CHANGED
@@ -21,6 +21,25 @@ function Signup() {
21
  setError('')
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
25
  e.preventDefault()
26
  setError('')
@@ -31,8 +50,7 @@ function Signup() {
31
  localStorage.setItem('access_token', data.access_token)
32
  navigate('/dashboard')
33
  } catch (err: unknown) {
34
- const axiosError = err as { response?: { data?: { detail?: string } } }
35
- setError(axiosError.response?.data?.detail || 'Signup failed. Please try again.')
36
  } finally {
37
  setLoading(false)
38
  }
@@ -90,13 +108,16 @@ function Signup() {
90
  name="password"
91
  type="password"
92
  required
93
- minLength={8}
94
  value={formData.password}
95
  onChange={handleChange}
96
  className="form-input"
97
- placeholder="At least 8 characters"
98
  disabled={loading}
99
  />
 
 
 
100
  </div>
101
 
102
  {error && <div className="form-error">{error}</div>}
 
21
  setError('')
22
  }
23
 
24
+ // Parse error response - handles both string and Pydantic v2 validation error format
25
+ const parseErrorMessage = (err: unknown): string => {
26
+ const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string; loc?: string[] }> } } }
27
+ const detail = axiosError.response?.data?.detail
28
+
29
+ if (!detail) return 'Signup failed. Please try again.'
30
+
31
+ // If detail is a string, return it directly
32
+ if (typeof detail === 'string') return detail
33
+
34
+ // If detail is an array (Pydantic v2 validation errors), extract messages
35
+ if (Array.isArray(detail)) {
36
+ const messages = detail.map(err => err.msg || 'Validation error').join('. ')
37
+ return messages || 'Validation failed. Please check your input.'
38
+ }
39
+
40
+ return 'Signup failed. Please try again.'
41
+ }
42
+
43
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
44
  e.preventDefault()
45
  setError('')
 
50
  localStorage.setItem('access_token', data.access_token)
51
  navigate('/dashboard')
52
  } catch (err: unknown) {
53
+ setError(parseErrorMessage(err))
 
54
  } finally {
55
  setLoading(false)
56
  }
 
108
  name="password"
109
  type="password"
110
  required
111
+ minLength={12}
112
  value={formData.password}
113
  onChange={handleChange}
114
  className="form-input"
115
+ placeholder="Min 12 chars, upper, lower, digit, special"
116
  disabled={loading}
117
  />
118
+ <small style={{ color: '#888', fontSize: '0.75rem', marginTop: '4px', display: 'block' }}>
119
+ 12+ characters with uppercase, lowercase, digit, and special character
120
+ </small>
121
  </div>
122
 
123
  {error && <div className="form-error">{error}</div>}