Christian Kniep commited on
Commit
dc4561e
·
1 Parent(s): ea1a26b

update to MVP

Browse files
Dockerfile CHANGED
@@ -9,12 +9,15 @@ RUN pip install --no-cache-dir -r requirements.txt
9
 
10
  COPY src/ src/
11
  COPY migrations/ migrations/
 
12
 
13
  RUN mkdir -p data
 
14
 
15
  EXPOSE 7860
16
 
 
17
  # Run with single worker to fix OAuth session persistence
18
  # TODO: Implement server-side session storage (Redis/Memcached) for multi-worker support
19
  # Increase timeout to 120s to handle slow LLM responses
20
- CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:7860", "--timeout", "120", "--graceful-timeout", "120", "src.app:app"]
 
9
 
10
  COPY src/ src/
11
  COPY migrations/ migrations/
12
+ COPY entrypoint.sh /app/entrypoint.sh
13
 
14
  RUN mkdir -p data
15
+ RUN chmod +x /app/entrypoint.sh
16
 
17
  EXPOSE 7860
18
 
19
+ # Use entrypoint script to run migrations before starting Gunicorn
20
  # Run with single worker to fix OAuth session persistence
21
  # TODO: Implement server-side session storage (Redis/Memcached) for multi-worker support
22
  # Increase timeout to 120s to handle slow LLM responses
23
+ ENTRYPOINT ["/app/entrypoint.sh"]
entrypoint.sh ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Webapp entrypoint script - runs database migrations before starting Gunicorn
3
+
4
+ set -e
5
+
6
+ echo "[ENTRYPOINT] Running database migrations..."
7
+
8
+ # Run Python migration script
9
+ python3 <<'PYEOF'
10
+ import sqlite3
11
+ import os
12
+
13
+ DB_PATH = os.environ.get('DATABASE_PATH', '/app/data/contacts.db')
14
+ MIGRATIONS_DIR = '/app/migrations'
15
+
16
+ print(f"[MIGRATION] Connecting to database: {DB_PATH}")
17
+ conn = sqlite3.connect(DB_PATH)
18
+ cursor = conn.cursor()
19
+
20
+ # Step 1: Check if contact_sessions table exists
21
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='contact_sessions'")
22
+ table_exists = cursor.fetchone() is not None
23
+
24
+ if not table_exists:
25
+ print("[MIGRATION] Initial database - creating tables from 001_create_tables.sql")
26
+
27
+ try:
28
+ with open(f'{MIGRATIONS_DIR}/001_create_tables.sql', 'r') as f:
29
+ schema_sql = f.read()
30
+
31
+ cursor.executescript(schema_sql)
32
+ conn.commit()
33
+ print(" ✓ Created user_profiles table")
34
+ print(" ✓ Created contact_sessions table")
35
+ print(" ✓ Created indexes")
36
+ except Exception as e:
37
+ print(f" ⚠ Failed to create initial schema: {e}")
38
+ conn.rollback()
39
+ raise
40
+
41
+ # Step 2: Check if normalized_name column exists (producer columns migration)
42
+ cursor.execute("PRAGMA table_info(contact_sessions)")
43
+ columns = [row[1] for row in cursor.fetchall()]
44
+
45
+ if 'normalized_name' not in columns:
46
+ print("[MIGRATION] Applying migration: Add producer-related columns")
47
+
48
+ try:
49
+ cursor.execute("ALTER TABLE contact_sessions ADD COLUMN normalized_name TEXT")
50
+ print(" ✓ Added normalized_name column")
51
+ except Exception as e:
52
+ print(f" ⚠ normalized_name: {e}")
53
+
54
+ try:
55
+ cursor.execute("ALTER TABLE contact_sessions ADD COLUMN sequence_number INTEGER DEFAULT 1")
56
+ print(" ✓ Added sequence_number column")
57
+ except Exception as e:
58
+ print(f" ⚠ sequence_number: {e}")
59
+
60
+ try:
61
+ cursor.execute("ALTER TABLE contact_sessions ADD COLUMN producer_id TEXT")
62
+ print(" ✓ Added producer_id column")
63
+ except Exception as e:
64
+ print(f" ⚠ producer_id: {e}")
65
+
66
+ try:
67
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_contact_sessions_producer ON contact_sessions(user_id, normalized_name)")
68
+ print(" ✓ Created index idx_contact_sessions_producer")
69
+ except Exception as e:
70
+ print(f" ⚠ index: {e}")
71
+
72
+ try:
73
+ cursor.execute("""
74
+ UPDATE contact_sessions
75
+ SET
76
+ normalized_name = LOWER(TRIM(contact_name)),
77
+ sequence_number = 1,
78
+ producer_id = user_id || '_' || LOWER(TRIM(contact_name)) || '_1'
79
+ WHERE normalized_name IS NULL
80
+ """)
81
+ print(f" ✓ Backfilled {cursor.rowcount} existing rows")
82
+ except Exception as e:
83
+ print(f" ⚠ backfill: {e}")
84
+
85
+ conn.commit()
86
+ print("[MIGRATION] Migration complete")
87
+ else:
88
+ print("[MIGRATION] Schema already up-to-date (normalized_name column exists)")
89
+
90
+ conn.close()
91
+ PYEOF
92
+
93
+ echo "[ENTRYPOINT] Starting Gunicorn..."
94
+
95
+ # Start Gunicorn with the provided arguments
96
+ exec gunicorn -w 1 -b 0.0.0.0:7860 --timeout 120 --graceful-timeout 120 src.app:app
migrations/002_add_producer_columns.sql ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Migration: Add producer-related columns to contact_sessions table
2
+ -- Feature: 001-refine-memory-producer-logic
3
+ -- Date: 2025-11-19
4
+ --
5
+ -- These columns support the producer attribution system for memory backend:
6
+ -- - normalized_name: Lowercase, stripped contact name for grouping (e.g., "jane doe")
7
+ -- - sequence_number: Incremental counter per (user_id, normalized_name) pair
8
+ -- - producer_id: Unique identifier = "{user_id}_{normalized_name}_{sequence_number}"
9
+
10
+ -- Add normalized_name column
11
+ ALTER TABLE contact_sessions ADD COLUMN normalized_name TEXT;
12
+
13
+ -- Add sequence_number column (defaults to 1 for existing rows)
14
+ ALTER TABLE contact_sessions ADD COLUMN sequence_number INTEGER DEFAULT 1;
15
+
16
+ -- Add producer_id column
17
+ ALTER TABLE contact_sessions ADD COLUMN producer_id TEXT;
18
+
19
+ -- Create index for efficient producer lookups
20
+ CREATE INDEX IF NOT EXISTS idx_contact_sessions_producer
21
+ ON contact_sessions(user_id, normalized_name);
22
+
23
+ -- Backfill existing rows with producer data
24
+ -- This ensures existing contacts get proper producer_id values
25
+ UPDATE contact_sessions
26
+ SET
27
+ normalized_name = LOWER(TRIM(contact_name)),
28
+ sequence_number = 1,
29
+ producer_id = user_id || '_' || LOWER(TRIM(contact_name)) || '_1'
30
+ WHERE normalized_name IS NULL;
src/__pycache__/app.cpython-311.pyc CHANGED
Binary files a/src/__pycache__/app.cpython-311.pyc and b/src/__pycache__/app.cpython-311.pyc differ
 
src/models/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/src/models/__pycache__/__init__.cpython-311.pyc and b/src/models/__pycache__/__init__.cpython-311.pyc differ
 
src/routes/__pycache__/auth.cpython-311.pyc CHANGED
Binary files a/src/routes/__pycache__/auth.cpython-311.pyc and b/src/routes/__pycache__/auth.cpython-311.pyc differ
 
src/routes/__pycache__/contacts.cpython-311.pyc CHANGED
Binary files a/src/routes/__pycache__/contacts.cpython-311.pyc and b/src/routes/__pycache__/contacts.cpython-311.pyc differ
 
src/routes/__pycache__/profile.cpython-311.pyc CHANGED
Binary files a/src/routes/__pycache__/profile.cpython-311.pyc and b/src/routes/__pycache__/profile.cpython-311.pyc differ
 
src/routes/auth.py CHANGED
@@ -135,6 +135,7 @@ def callback():
135
  session["display_name"] = display_name
136
  session["profile_picture_url"] = profile_picture_url
137
  session["session_id"] = user_profile.session_id
 
138
  session["access_token"] = token.get("access_token")
139
 
140
  flash(f"Welcome back, {display_name}!", "success")
 
135
  session["display_name"] = display_name
136
  session["profile_picture_url"] = profile_picture_url
137
  session["session_id"] = user_profile.session_id
138
+ session["profile_session_id"] = user_profile.session_id # Feature: 001-contact-session-fixes - cache for facts/messages
139
  session["access_token"] = token.get("access_token")
140
 
141
  flash(f"Welcome back, {display_name}!", "success")
src/routes/contacts.py CHANGED
@@ -129,29 +129,71 @@ def create_contact():
129
  flash("Description cannot exceed 500 characters.", "danger")
130
  return redirect(url_for("contacts.list_contacts"))
131
 
 
 
 
 
 
 
132
  try:
133
- # Create backend session first to get the session_id
134
- backend_api = backend_client.BackendAPIClient()
135
  title = f"Contact: {contact_name}"
 
136
  backend_response = backend_api.create_session(title=title, user_id=user_id)
137
  session_id = backend_response.get("session_id")
138
 
139
  if not session_id:
140
  raise ValueError("Backend API did not return a session_id")
141
 
142
- # Create contact session in SQLite with the backend-generated session_id
143
- contact = storage_service.create_contact_session_with_id(
144
- user_id=user_id,
145
- session_id=session_id,
146
- contact_name=contact_name,
147
- contact_description=contact_description,
148
- is_reference=False
149
- )
150
-
151
- flash(f"Contact '{contact_name}' created successfully!", "success")
152
- logger.info(f"Created contact {contact.session_id} for user {user_id}")
153
 
154
- return redirect(url_for("contacts.view_contact", session_id=contact.session_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  except ValueError as e:
157
  # Validation errors or contact limit reached
@@ -160,7 +202,8 @@ def create_contact():
160
  return redirect(url_for("contacts.list_contacts"))
161
 
162
  except Exception as e:
163
- logger.error(f"Error creating contact for user {user_id}: {e}")
 
164
  flash("Error creating contact. Please try again.", "danger")
165
  return redirect(url_for("contacts.list_contacts"))
166
 
@@ -186,10 +229,11 @@ def view_contact(session_id: str):
186
  flash("You don't have permission to view this contact.", "danger")
187
  return redirect(url_for("contacts.list_contacts"))
188
 
189
- # Get messages from backend API
190
  backend_api = backend_client.BackendAPIClient()
191
  try:
192
- session_data = backend_api.get_session(session_id)
 
193
  messages = session_data.get("messages", [])
194
 
195
  # Separate facts and chat messages
@@ -345,6 +389,10 @@ def send_message(session_id: str):
345
  flash("Message cannot exceed 10,000 characters.", "danger")
346
  return redirect(url_for("contacts.view_contact", session_id=session_id))
347
 
 
 
 
 
348
  try:
349
  # Get contact to verify ownership
350
  contact = storage_service.get_contact_session(session_id)
@@ -356,50 +404,46 @@ def send_message(session_id: str):
356
  flash("You don't have permission to message this contact.", "danger")
357
  return redirect(url_for("contacts.list_contacts"))
358
 
359
- # Get user profile to include as reference
360
- user_profile = storage_service.get_user_profile(user_id)
361
- profile_session_id = user_profile.session_id if user_profile else f"{user_id}_session"
 
 
 
362
 
363
  # Send message with profile session as reference
 
364
  backend_api = backend_client.BackendAPIClient()
365
  response = backend_api.send_message(
366
  session_id=session_id,
367
  content=content,
 
368
  mode="chat",
369
  sender="user",
370
- reference_session_ids=[profile_session_id]
371
  )
372
 
373
- # Check if we got an LLM response and save it as an assistant message
374
  llm_response = response.get("llm_response")
375
  if llm_response:
376
  if llm_response.get("error"):
377
  error_msg = llm_response["error"].get("message", "Unknown error")
378
  flash(f"AI response error: {error_msg}", "warning")
379
- logger.warning(f"LLM error for contact {session_id}: {error_msg}")
380
  elif llm_response.get("content"):
381
- # Save the assistant's response as a separate message
382
- try:
383
- backend_api.send_message(
384
- session_id=session_id,
385
- content=llm_response["content"],
386
- mode="chat",
387
- sender="assistant",
388
- reference_session_ids=None # Assistant response doesn't need references
389
- )
390
- flash("Message sent! AI responded.", "success")
391
- logger.info(f"Saved AI response in contact {session_id}")
392
- except Exception as e:
393
- logger.error(f"Failed to save AI response: {e}")
394
- flash("Message sent, but failed to save AI response.", "warning")
395
  else:
396
  flash("Message sent successfully!", "success")
397
  else:
398
  flash("Message sent successfully!", "success")
399
 
400
  # Update last interaction timestamp
401
- storage_service.update_last_interaction(session_id)
402
 
 
 
403
  logger.info(f"Sent message in contact {session_id} for user {user_id}")
404
 
405
  return redirect(url_for("contacts.view_contact", session_id=session_id))
@@ -435,6 +479,10 @@ def add_fact(session_id: str):
435
  flash("Fact cannot exceed 2,000 characters.", "danger")
436
  return redirect(url_for("contacts.view_contact", session_id=session_id))
437
 
 
 
 
 
438
  try:
439
  # Get contact to verify ownership
440
  contact = storage_service.get_contact_session(session_id)
@@ -446,27 +494,39 @@ def add_fact(session_id: str):
446
  flash("You don't have permission to add facts to this contact.", "danger")
447
  return redirect(url_for("contacts.list_contacts"))
448
 
449
- # Send fact with proper attribution (Feature: 001-refine-memory-producer-logic)
450
- # Contact facts: producer=contact.producer_id, produced_for=user_id
 
 
 
 
 
 
 
451
  producer_id = contact.producer_id or user_id
452
  logger.info(
453
- f"Saving contact fact: user_id={user_id}, contact={contact.contact_name}, "
454
- f"producer={producer_id}, produced_for={user_id}, content_length={len(content)}"
455
  )
456
  backend_api = backend_client.BackendAPIClient()
457
  backend_api.send_message(
458
  session_id=session_id,
459
  content=content,
 
460
  mode="memorize",
461
- producer=producer_id, # Use contact's producer_id if available
462
- produced_for=user_id, # Produced for the user who owns the contact
 
463
  )
464
 
465
  # Update last interaction timestamp
466
- storage_service.update_last_interaction(session_id)
 
 
 
467
 
468
  # Fetch updated facts to ensure immediate visibility (T017)
469
- session_data = backend_api.get_session(session_id)
470
  messages = session_data.get("messages", [])
471
  facts = [m for m in messages if m.get("mode") == "memorize"]
472
  chat_messages = [m for m in messages if m.get("mode") == "chat"]
@@ -486,7 +546,7 @@ def add_fact(session_id: str):
486
  flash(str(e), "danger")
487
  # Preserve input on error (T016)
488
  backend_api = backend_client.BackendAPIClient()
489
- session_data = backend_api.get_session(session_id)
490
  messages = session_data.get("messages", [])
491
  facts = [m for m in messages if m.get("mode") == "memorize"]
492
  chat_messages = [m for m in messages if m.get("mode") == "chat"]
@@ -504,7 +564,7 @@ def add_fact(session_id: str):
504
  # Preserve input on error (T016)
505
  backend_api = backend_client.BackendAPIClient()
506
  try:
507
- session_data = backend_api.get_session(session_id)
508
  messages = session_data.get("messages", [])
509
  facts = [m for m in messages if m.get("mode") == "memorize"]
510
  chat_messages = [m for m in messages if m.get("mode") == "chat"]
 
129
  flash("Description cannot exceed 500 characters.", "danger")
130
  return redirect(url_for("contacts.list_contacts"))
131
 
132
+ # Feature: 001-contact-session-fixes - Two-phase commit for dual-database synchronization
133
+ import time
134
+ start_time = time.time()
135
+ backend_api = backend_client.BackendAPIClient()
136
+ session_id = None
137
+
138
  try:
139
+ # Phase 1: Create backend session in PostgreSQL
 
140
  title = f"Contact: {contact_name}"
141
+ logger.info(f"[CONTACT_CREATE] Phase 1: Creating backend session for user {user_id}, contact '{contact_name}'")
142
  backend_response = backend_api.create_session(title=title, user_id=user_id)
143
  session_id = backend_response.get("session_id")
144
 
145
  if not session_id:
146
  raise ValueError("Backend API did not return a session_id")
147
 
148
+ logger.info(f"[BACKEND_API] POST /sessions response: 201, session_id={session_id}")
 
 
 
 
 
 
 
 
 
 
149
 
150
+ # Phase 2: Write to SQLite with backend-generated session_id
151
+ try:
152
+ logger.info(f"[CONTACT_CREATE] Phase 2: Writing to SQLite, session_id={session_id}")
153
+ contact = storage_service.create_contact_session_with_id(
154
+ user_id=user_id,
155
+ session_id=session_id,
156
+ contact_name=contact_name,
157
+ contact_description=contact_description,
158
+ is_reference=False
159
+ )
160
+ logger.info(f"[STORAGE] SQLite write successful: session_id={session_id}")
161
+
162
+ # Success - both databases synchronized
163
+ elapsed = (time.time() - start_time) * 1000
164
+ logger.info(f"[PERF] Contact creation took {elapsed:.1f}ms (target <150ms)")
165
+ flash(f"Contact '{contact_name}' created successfully!", "success")
166
+ logger.info(f"Created contact {contact.session_id} for user {user_id}")
167
+
168
+ return redirect(url_for("contacts.view_contact", session_id=contact.session_id))
169
+
170
+ except Exception as sqlite_error:
171
+ # Phase 2 failed - rollback Phase 1
172
+ logger.error(f"[STORAGE] Failed to write SQLite: {sqlite_error}")
173
+ logger.info(f"[ROLLBACK] Attempting to delete backend session: {session_id}")
174
+
175
+ try:
176
+ rollback_success = backend_api.delete_session(session_id, user_id)
177
+ if rollback_success:
178
+ logger.info(f"[ROLLBACK] Deleted backend session: {session_id}")
179
+ else:
180
+ logger.error(f"[ROLLBACK] DELETE /sessions/{session_id} returned unexpected status")
181
+ except Exception as rollback_error:
182
+ # Rollback failed - critical error requiring manual cleanup
183
+ logger.critical(
184
+ f"[ROLLBACK] Failed to delete session {session_id}: {rollback_error}",
185
+ extra={
186
+ "session_id": session_id,
187
+ "user_id": user_id,
188
+ "timestamp": time.time(),
189
+ "error_type": "rollback_failure",
190
+ "original_error": str(sqlite_error)
191
+ }
192
+ )
193
+
194
+ # Fail the entire operation
195
+ flash("Error creating contact. Please try again.", "danger")
196
+ return redirect(url_for("contacts.list_contacts"))
197
 
198
  except ValueError as e:
199
  # Validation errors or contact limit reached
 
202
  return redirect(url_for("contacts.list_contacts"))
203
 
204
  except Exception as e:
205
+ # Backend API call failed (Phase 1) - no rollback needed
206
+ logger.error(f"[BACKEND_API] Failed to create session: {e}")
207
  flash("Error creating contact. Please try again.", "danger")
208
  return redirect(url_for("contacts.list_contacts"))
209
 
 
229
  flash("You don't have permission to view this contact.", "danger")
230
  return redirect(url_for("contacts.list_contacts"))
231
 
232
+ # Get messages from backend API, including producer_id for memory search
233
  backend_api = backend_client.BackendAPIClient()
234
  try:
235
+ # Pass producer_id to include contact's memories in search
236
+ session_data = backend_api.get_session(session_id, user_id, producer_id=contact.producer_id)
237
  messages = session_data.get("messages", [])
238
 
239
  # Separate facts and chat messages
 
389
  flash("Message cannot exceed 10,000 characters.", "danger")
390
  return redirect(url_for("contacts.view_contact", session_id=session_id))
391
 
392
+ # Feature: 001-contact-session-fixes - Send messages with profile session reference
393
+ import time
394
+ start_time = time.time()
395
+
396
  try:
397
  # Get contact to verify ownership
398
  contact = storage_service.get_contact_session(session_id)
 
404
  flash("You don't have permission to message this contact.", "danger")
405
  return redirect(url_for("contacts.list_contacts"))
406
 
407
+ # Get profile session ID from Flask session cache
408
+ profile_session_id = session.get("profile_session_id")
409
+ if not profile_session_id:
410
+ flash("Error: Profile session not found. Please log in again.", "warning")
411
+ logger.warning(f"[MESSAGE] Missing profile_session_id for user {user_id}")
412
+ return redirect(url_for("contacts.view_contact", session_id=session_id))
413
 
414
  # Send message with profile session as reference
415
+ logger.info(f"[MESSAGE] Sending chat message: user_id={user_id}, contact={contact.contact_name}, reference={profile_session_id}, content_length={len(content)}")
416
  backend_api = backend_client.BackendAPIClient()
417
  response = backend_api.send_message(
418
  session_id=session_id,
419
  content=content,
420
+ user_id=user_id,
421
  mode="chat",
422
  sender="user",
423
+ reference_session_ids=[profile_session_id] # Link to profile for AI context
424
  )
425
 
426
+ # Check if we got an LLM response
427
  llm_response = response.get("llm_response")
428
  if llm_response:
429
  if llm_response.get("error"):
430
  error_msg = llm_response["error"].get("message", "Unknown error")
431
  flash(f"AI response error: {error_msg}", "warning")
432
+ logger.warning(f"[MESSAGE] LLM error for contact {session_id}: {error_msg}")
433
  elif llm_response.get("content"):
434
+ # Backend typically saves assistant response automatically, but log it
435
+ flash("Message sent! AI responded.", "success")
436
+ logger.info(f"[MESSAGE] AI response received for contact {session_id}")
 
 
 
 
 
 
 
 
 
 
 
437
  else:
438
  flash("Message sent successfully!", "success")
439
  else:
440
  flash("Message sent successfully!", "success")
441
 
442
  # Update last interaction timestamp
443
+ storage_service.update_contact_last_interaction(session_id)
444
 
445
+ elapsed = (time.time() - start_time) * 1000
446
+ logger.info(f"[PERF] Message send took {elapsed:.1f}ms (target <150ms, excluding AI generation)")
447
  logger.info(f"Sent message in contact {session_id} for user {user_id}")
448
 
449
  return redirect(url_for("contacts.view_contact", session_id=session_id))
 
479
  flash("Fact cannot exceed 2,000 characters.", "danger")
480
  return redirect(url_for("contacts.view_contact", session_id=session_id))
481
 
482
+ # Feature: 001-contact-session-fixes - Add facts with profile session reference
483
+ import time
484
+ start_time = time.time()
485
+
486
  try:
487
  # Get contact to verify ownership
488
  contact = storage_service.get_contact_session(session_id)
 
494
  flash("You don't have permission to add facts to this contact.", "danger")
495
  return redirect(url_for("contacts.list_contacts"))
496
 
497
+ # Get profile session ID from Flask session cache
498
+ profile_session_id = session.get("profile_session_id")
499
+ if not profile_session_id:
500
+ flash("Error: Profile session not found. Please log in again.", "warning")
501
+ logger.warning(f"[FACT] Missing profile_session_id for user {user_id}")
502
+ return redirect(url_for("contacts.view_contact", session_id=session_id))
503
+
504
+ # Send fact with proper attribution and profile reference
505
+ # Contact facts: producer=contact.producer_id, produced_for=user_id, reference=profile_session_id
506
  producer_id = contact.producer_id or user_id
507
  logger.info(
508
+ f"[FACT] Saving contact fact: user_id={user_id}, contact={contact.contact_name}, "
509
+ f"producer={producer_id}, produced_for={user_id}, reference={profile_session_id}, content_length={len(content)}"
510
  )
511
  backend_api = backend_client.BackendAPIClient()
512
  backend_api.send_message(
513
  session_id=session_id,
514
  content=content,
515
+ user_id=user_id,
516
  mode="memorize",
517
+ producer=producer_id,
518
+ produced_for=user_id,
519
+ reference_session_ids=[profile_session_id] # Link to profile for AI context
520
  )
521
 
522
  # Update last interaction timestamp
523
+ storage_service.update_contact_last_interaction(session_id)
524
+
525
+ elapsed = (time.time() - start_time) * 1000
526
+ logger.info(f"[PERF] Fact save took {elapsed:.1f}ms (target <150ms)")
527
 
528
  # Fetch updated facts to ensure immediate visibility (T017)
529
+ session_data = backend_api.get_session(session_id, user_id, producer_id=contact.producer_id)
530
  messages = session_data.get("messages", [])
531
  facts = [m for m in messages if m.get("mode") == "memorize"]
532
  chat_messages = [m for m in messages if m.get("mode") == "chat"]
 
546
  flash(str(e), "danger")
547
  # Preserve input on error (T016)
548
  backend_api = backend_client.BackendAPIClient()
549
+ session_data = backend_api.get_session(session_id, user_id, producer_id=contact.producer_id)
550
  messages = session_data.get("messages", [])
551
  facts = [m for m in messages if m.get("mode") == "memorize"]
552
  chat_messages = [m for m in messages if m.get("mode") == "chat"]
 
564
  # Preserve input on error (T016)
565
  backend_api = backend_client.BackendAPIClient()
566
  try:
567
+ session_data = backend_api.get_session(session_id, user_id, producer_id=contact.producer_id)
568
  messages = session_data.get("messages", [])
569
  facts = [m for m in messages if m.get("mode") == "memorize"]
570
  chat_messages = [m for m in messages if m.get("mode") == "chat"]
src/routes/profile.py CHANGED
@@ -35,7 +35,7 @@ def view_profile():
35
  return redirect(url_for("auth.logout"))
36
 
37
  # Get profile facts from backend (mode="memorize")
38
- facts = backend_client.get_messages(user_profile.session_id, mode="memorize")
39
 
40
  return render_template(
41
  "profile/view.html",
@@ -83,13 +83,14 @@ def add_fact():
83
  backend_client.send_message(
84
  session_id=user_profile.session_id,
85
  content=fact_content,
 
86
  mode="memorize",
87
  producer=user_id, # User is the producer
88
  produced_for="prepmate", # Produced for the prepmate system
89
  )
90
 
91
  # Fetch updated facts to ensure immediate visibility (T010)
92
- facts = backend_client.get_messages(user_profile.session_id, mode="memorize")
93
 
94
  flash("Fact added successfully.", "success")
95
  logger.info(f"Profile fact saved successfully for user {user_id}")
@@ -105,7 +106,7 @@ def add_fact():
105
  flash(f"Error adding fact: {str(e)}", "error")
106
  # Preserve input on error by passing it to template
107
  user_profile = get_user_profile(user_id)
108
- facts = backend_client.get_messages(user_profile.session_id, mode="memorize") if user_profile else []
109
  return render_template(
110
  "profile/view.html",
111
  user_profile=user_profile,
 
35
  return redirect(url_for("auth.logout"))
36
 
37
  # Get profile facts from backend (mode="memorize")
38
+ facts = backend_client.get_messages(user_profile.session_id, user_id, mode="memorize")
39
 
40
  return render_template(
41
  "profile/view.html",
 
83
  backend_client.send_message(
84
  session_id=user_profile.session_id,
85
  content=fact_content,
86
+ user_id=user_id,
87
  mode="memorize",
88
  producer=user_id, # User is the producer
89
  produced_for="prepmate", # Produced for the prepmate system
90
  )
91
 
92
  # Fetch updated facts to ensure immediate visibility (T010)
93
+ facts = backend_client.get_messages(user_profile.session_id, user_id, mode="memorize")
94
 
95
  flash("Fact added successfully.", "success")
96
  logger.info(f"Profile fact saved successfully for user {user_id}")
 
106
  flash(f"Error adding fact: {str(e)}", "error")
107
  # Preserve input on error by passing it to template
108
  user_profile = get_user_profile(user_id)
109
+ facts = backend_client.get_messages(user_profile.session_id, user_id, mode="memorize") if user_profile else []
110
  return render_template(
111
  "profile/view.html",
112
  user_profile=user_profile,
src/services/__pycache__/auth_service.cpython-311.pyc CHANGED
Binary files a/src/services/__pycache__/auth_service.cpython-311.pyc and b/src/services/__pycache__/auth_service.cpython-311.pyc differ
 
src/services/__pycache__/backend_client.cpython-311.pyc CHANGED
Binary files a/src/services/__pycache__/backend_client.cpython-311.pyc and b/src/services/__pycache__/backend_client.cpython-311.pyc differ
 
src/services/__pycache__/storage_service.cpython-311.pyc CHANGED
Binary files a/src/services/__pycache__/storage_service.cpython-311.pyc and b/src/services/__pycache__/storage_service.cpython-311.pyc differ
 
src/services/backend_client.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional
9
 
10
  import requests
11
  from opentelemetry import trace
 
12
  from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
13
 
14
  from ..models import Message
@@ -28,15 +29,20 @@ class BackendAPIClient:
28
  self.timeout = int(os.getenv("BACKEND_API_TIMEOUT", "5"))
29
  self.bearer_token = os.getenv("API_BEARER_TOKEN", "")
30
 
31
- def _get_headers(self) -> Dict[str, str]:
32
  """
33
- Get HTTP headers including Authorization bearer token and trace context.
 
 
 
34
 
35
  Automatically injects OpenTelemetry trace context from current span.
36
  """
37
  headers = {"Content-Type": "application/json"}
38
  if self.bearer_token:
39
  headers["Authorization"] = f"Bearer {self.bearer_token}"
 
 
40
 
41
  # Inject trace context into headers automatically (only if tracing enabled)
42
  try:
@@ -64,12 +70,14 @@ class BackendAPIClient:
64
  # Not in Flask request context - ignore
65
  pass
66
 
67
- def get_session(self, session_id: str) -> Dict[str, Any]:
68
  """
69
  Get full session including all messages (facts + chat messages).
70
 
71
  Args:
72
  session_id: Profile or contact session ID
 
 
73
 
74
  Returns:
75
  Session dict with messages array
@@ -78,6 +86,11 @@ class BackendAPIClient:
78
  BackendAPIError: If API request fails
79
  """
80
  url = f"{self.base_url}/sessions/{session_id}"
 
 
 
 
 
81
 
82
  # Create span for backend call
83
  tracer = trace.get_tracer(__name__)
@@ -85,10 +98,13 @@ class BackendAPIClient:
85
  span.set_attribute("http.method", "GET")
86
  span.set_attribute("http.url", url)
87
  span.set_attribute("session_id", session_id)
 
 
 
88
 
89
  start_time = time.time()
90
  try:
91
- response = requests.get(url, headers=self._get_headers(), timeout=self.timeout)
92
  response.raise_for_status()
93
 
94
  span.set_attribute("http.status_code", response.status_code)
@@ -108,6 +124,7 @@ class BackendAPIClient:
108
  self,
109
  session_id: str,
110
  content: str,
 
111
  mode: str = "chat",
112
  sender: Optional[str] = "user",
113
  reference_session_ids: Optional[List[str]] = None,
@@ -120,6 +137,7 @@ class BackendAPIClient:
120
  Args:
121
  session_id: Profile or contact session ID
122
  content: Message or fact content
 
123
  mode: 'chat' for messages, 'memorize' for facts
124
  sender: 'user' or 'assistant' (only for mode='chat')
125
  reference_session_ids: Optional list of session IDs to use as context (e.g., user's profile session)
@@ -133,6 +151,11 @@ class BackendAPIClient:
133
  BackendAPIError: If API request fails
134
  """
135
  url = f"{self.base_url}/sessions/{session_id}/messages"
 
 
 
 
 
136
 
137
  payload = {"mode": mode, "message": content, "save_to_memory": True}
138
 
@@ -140,7 +163,7 @@ class BackendAPIClient:
140
  if mode == "chat" and sender:
141
  payload["sender"] = sender
142
 
143
- # Add reference session IDs if provided
144
  if reference_session_ids:
145
  payload["reference_session_ids"] = reference_session_ids
146
 
@@ -158,11 +181,22 @@ class BackendAPIClient:
158
  span.set_attribute("http.method", "POST")
159
  span.set_attribute("http.url", url)
160
  span.set_attribute("session_id", session_id)
 
161
  span.set_attribute("mode", mode)
162
  span.set_attribute("content_length", len(content))
163
 
 
 
 
 
 
 
 
 
 
 
164
  try:
165
- response = requests.post(url, json=payload, headers=self._get_headers(), timeout=self.timeout)
166
  response.raise_for_status()
167
  span.set_attribute("http.status_code", response.status_code)
168
  return response.json()
@@ -227,6 +261,63 @@ class BackendAPIClient:
227
  finally:
228
  self._track_latency(start_time)
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  def list_sessions(self, user_id: str) -> List[Dict[str, Any]]:
231
  """
232
  List all sessions for a user.
@@ -253,7 +344,7 @@ class BackendAPIClient:
253
  span.set_attribute("user_id", user_id)
254
 
255
  try:
256
- response = requests.get(url, params=params, headers=self._get_headers(), timeout=self.timeout)
257
  response.raise_for_status()
258
  span.set_attribute("http.status_code", response.status_code)
259
  return response.json()
@@ -270,13 +361,14 @@ class BackendAPIClient:
270
  self._track_latency(start_time)
271
 
272
  def get_messages(
273
- self, session_id: str, mode: Optional[str] = None
274
  ) -> List[Message]:
275
  """
276
  Get messages from a session, optionally filtered by mode.
277
 
278
  Args:
279
  session_id: Session ID
 
280
  mode: Optional filter - 'chat' or 'memorize'
281
 
282
  Returns:
@@ -285,7 +377,7 @@ class BackendAPIClient:
285
  Raises:
286
  BackendAPIError: If API request fails
287
  """
288
- session = self.get_session(session_id)
289
  messages_data = session.get("messages", [])
290
 
291
  messages = []
 
9
 
10
  import requests
11
  from opentelemetry import trace
12
+ from opentelemetry.trace import Status, StatusCode
13
  from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
14
 
15
  from ..models import Message
 
29
  self.timeout = int(os.getenv("BACKEND_API_TIMEOUT", "5"))
30
  self.bearer_token = os.getenv("API_BEARER_TOKEN", "")
31
 
32
+ def _get_headers(self, user_id: str = None) -> Dict[str, str]:
33
  """
34
+ Get HTTP headers including Authorization bearer token, user ID, and trace context.
35
+
36
+ Args:
37
+ user_id: Optional user ID to include in X-User-ID header
38
 
39
  Automatically injects OpenTelemetry trace context from current span.
40
  """
41
  headers = {"Content-Type": "application/json"}
42
  if self.bearer_token:
43
  headers["Authorization"] = f"Bearer {self.bearer_token}"
44
+ if user_id:
45
+ headers["X-User-ID"] = user_id
46
 
47
  # Inject trace context into headers automatically (only if tracing enabled)
48
  try:
 
70
  # Not in Flask request context - ignore
71
  pass
72
 
73
+ def get_session(self, session_id: str, user_id: str, producer_id: str = None) -> Dict[str, Any]:
74
  """
75
  Get full session including all messages (facts + chat messages).
76
 
77
  Args:
78
  session_id: Profile or contact session ID
79
+ user_id: User ID (required for API authentication)
80
+ producer_id: Optional producer ID for contact sessions (e.g., "testuser_jane_1")
81
 
82
  Returns:
83
  Session dict with messages array
 
86
  BackendAPIError: If API request fails
87
  """
88
  url = f"{self.base_url}/sessions/{session_id}"
89
+
90
+ # Add producer_id query parameter if provided
91
+ params = {}
92
+ if producer_id:
93
+ params["producer_id"] = producer_id
94
 
95
  # Create span for backend call
96
  tracer = trace.get_tracer(__name__)
 
98
  span.set_attribute("http.method", "GET")
99
  span.set_attribute("http.url", url)
100
  span.set_attribute("session_id", session_id)
101
+ span.set_attribute("user_id", user_id)
102
+ if producer_id:
103
+ span.set_attribute("producer_id", producer_id)
104
 
105
  start_time = time.time()
106
  try:
107
+ response = requests.get(url, params=params, headers=self._get_headers(user_id), timeout=self.timeout)
108
  response.raise_for_status()
109
 
110
  span.set_attribute("http.status_code", response.status_code)
 
124
  self,
125
  session_id: str,
126
  content: str,
127
+ user_id: str,
128
  mode: str = "chat",
129
  sender: Optional[str] = "user",
130
  reference_session_ids: Optional[List[str]] = None,
 
137
  Args:
138
  session_id: Profile or contact session ID
139
  content: Message or fact content
140
+ user_id: User ID (required for API authentication)
141
  mode: 'chat' for messages, 'memorize' for facts
142
  sender: 'user' or 'assistant' (only for mode='chat')
143
  reference_session_ids: Optional list of session IDs to use as context (e.g., user's profile session)
 
151
  BackendAPIError: If API request fails
152
  """
153
  url = f"{self.base_url}/sessions/{session_id}/messages"
154
+
155
+ # Add reference_id as query parameter if provided
156
+ params = {}
157
+ if reference_session_ids and len(reference_session_ids) > 0:
158
+ params["reference_id"] = reference_session_ids[0]
159
 
160
  payload = {"mode": mode, "message": content, "save_to_memory": True}
161
 
 
163
  if mode == "chat" and sender:
164
  payload["sender"] = sender
165
 
166
+ # Add reference session IDs if provided (also in body for backward compatibility)
167
  if reference_session_ids:
168
  payload["reference_session_ids"] = reference_session_ids
169
 
 
181
  span.set_attribute("http.method", "POST")
182
  span.set_attribute("http.url", url)
183
  span.set_attribute("session_id", session_id)
184
+ span.set_attribute("user_id", user_id)
185
  span.set_attribute("mode", mode)
186
  span.set_attribute("content_length", len(content))
187
 
188
+ # Add reference_id to trace if provided (profile session for contact messages)
189
+ if reference_session_ids and len(reference_session_ids) > 0:
190
+ span.set_attribute("reference_id", reference_session_ids[0])
191
+
192
+ # Add producer and produced_for to span for memory attribution tracking
193
+ if producer:
194
+ span.set_attribute("producer", producer)
195
+ if produced_for:
196
+ span.set_attribute("produced_for", produced_for)
197
+
198
  try:
199
+ response = requests.post(url, params=params, json=payload, headers=self._get_headers(user_id), timeout=self.timeout)
200
  response.raise_for_status()
201
  span.set_attribute("http.status_code", response.status_code)
202
  return response.json()
 
261
  finally:
262
  self._track_latency(start_time)
263
 
264
+ def delete_session(self, session_id: str, user_id: str) -> bool:
265
+ """
266
+ Delete a session (rollback mechanism for two-phase commit).
267
+
268
+ Feature: 001-contact-session-fixes
269
+
270
+ Args:
271
+ session_id: Session ID to delete
272
+ user_id: User ID who owns the session
273
+
274
+ Returns:
275
+ True if deleted or already gone (204/404), False on error
276
+
277
+ Raises:
278
+ BackendAPIError: If API request fails with non-recoverable error
279
+ """
280
+ url = f"{self.base_url}/sessions/{session_id}"
281
+
282
+ # Create span for backend call with OpenTelemetry
283
+ tracer = trace.get_tracer(__name__)
284
+ start_time = time.time()
285
+
286
+ # Add user_id header required by API
287
+ headers = self._get_headers()
288
+ headers["X-User-ID"] = user_id
289
+
290
+ with tracer.start_as_current_span("backend.delete_session") as span:
291
+ span.set_attribute("http.method", "DELETE")
292
+ span.set_attribute("http.url", url)
293
+ span.set_attribute("session_id", session_id)
294
+ span.set_attribute("user_id", user_id)
295
+
296
+ try:
297
+ response = requests.delete(url, headers=headers, timeout=self.timeout)
298
+
299
+ # 204 = successfully deleted, 404 = already gone (both acceptable for rollback)
300
+ if response.status_code in (204, 404):
301
+ span.set_attribute("http.status_code", response.status_code)
302
+ span.set_attribute("rollback_success", True)
303
+ return True
304
+ else:
305
+ span.set_attribute("http.status_code", response.status_code)
306
+ span.set_attribute("rollback_success", False)
307
+ span.add_event("unexpected_status", {"status_code": response.status_code})
308
+ return False
309
+
310
+ except requests.exceptions.Timeout:
311
+ span.set_attribute("error", True)
312
+ span.add_event("timeout", {"timeout": self.timeout})
313
+ raise BackendAPIError(f"Delete session timed out after {self.timeout}s")
314
+ except requests.exceptions.RequestException as e:
315
+ span.set_attribute("error", True)
316
+ span.add_event("error", {"message": str(e)})
317
+ raise BackendAPIError(f"Failed to delete session: {str(e)}")
318
+ finally:
319
+ self._track_latency(start_time)
320
+
321
  def list_sessions(self, user_id: str) -> List[Dict[str, Any]]:
322
  """
323
  List all sessions for a user.
 
344
  span.set_attribute("user_id", user_id)
345
 
346
  try:
347
+ response = requests.get(url, params=params, headers=self._get_headers(user_id), timeout=self.timeout)
348
  response.raise_for_status()
349
  span.set_attribute("http.status_code", response.status_code)
350
  return response.json()
 
361
  self._track_latency(start_time)
362
 
363
  def get_messages(
364
+ self, session_id: str, user_id: str, mode: Optional[str] = None
365
  ) -> List[Message]:
366
  """
367
  Get messages from a session, optionally filtered by mode.
368
 
369
  Args:
370
  session_id: Session ID
371
+ user_id: User ID (required for API authentication)
372
  mode: Optional filter - 'chat' or 'memorize'
373
 
374
  Returns:
 
377
  Raises:
378
  BackendAPIError: If API request fails
379
  """
380
+ session = self.get_session(session_id, user_id)
381
  messages_data = session.get("messages", [])
382
 
383
  messages = []
src/services/storage_service.py CHANGED
@@ -429,6 +429,11 @@ def list_contact_sessions(
429
  rows = cursor.fetchall()
430
  conn.close()
431
 
 
 
 
 
 
432
  contacts = [
433
  ContactSession(
434
  session_id=row["session_id"],
@@ -438,9 +443,9 @@ def list_contact_sessions(
438
  is_reference=bool(row["is_reference"]),
439
  created_at=datetime.fromisoformat(row["created_at"]),
440
  last_interaction=datetime.fromisoformat(row["last_interaction"]),
441
- normalized_name=row.get("normalized_name"),
442
- sequence_number=row.get("sequence_number"),
443
- producer_id=row.get("producer_id"),
444
  )
445
  for row in rows
446
  ]
@@ -472,6 +477,9 @@ def get_contact_session(session_id: str) -> Optional[ContactSession]:
472
  if not row:
473
  return None
474
 
 
 
 
475
  return ContactSession(
476
  session_id=row["session_id"],
477
  user_id=row["user_id"],
@@ -480,9 +488,9 @@ def get_contact_session(session_id: str) -> Optional[ContactSession]:
480
  is_reference=bool(row["is_reference"]),
481
  created_at=datetime.fromisoformat(row["created_at"]),
482
  last_interaction=datetime.fromisoformat(row["last_interaction"]),
483
- normalized_name=row.get("normalized_name"),
484
- sequence_number=row.get("sequence_number"),
485
- producer_id=row.get("producer_id"),
486
  )
487
 
488
 
@@ -560,6 +568,41 @@ def update_contact_session(
560
  )
561
 
562
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  def delete_contact_session(session_id: str) -> None:
564
  """
565
  Delete a contact session from SQLite.
 
429
  rows = cursor.fetchall()
430
  conn.close()
431
 
432
+ # Helper function to safely access optional columns
433
+ def safe_row_access(row, key):
434
+ """Safely access optional column, returns None if not present."""
435
+ return row[key] if key in row.keys() else None
436
+
437
  contacts = [
438
  ContactSession(
439
  session_id=row["session_id"],
 
443
  is_reference=bool(row["is_reference"]),
444
  created_at=datetime.fromisoformat(row["created_at"]),
445
  last_interaction=datetime.fromisoformat(row["last_interaction"]),
446
+ normalized_name=safe_row_access(row, "normalized_name"),
447
+ sequence_number=safe_row_access(row, "sequence_number"),
448
+ producer_id=safe_row_access(row, "producer_id"),
449
  )
450
  for row in rows
451
  ]
 
477
  if not row:
478
  return None
479
 
480
+ # Get column names to safely access optional fields
481
+ columns = row.keys()
482
+
483
  return ContactSession(
484
  session_id=row["session_id"],
485
  user_id=row["user_id"],
 
488
  is_reference=bool(row["is_reference"]),
489
  created_at=datetime.fromisoformat(row["created_at"]),
490
  last_interaction=datetime.fromisoformat(row["last_interaction"]),
491
+ normalized_name=row["normalized_name"] if "normalized_name" in columns else None,
492
+ sequence_number=row["sequence_number"] if "sequence_number" in columns else None,
493
+ producer_id=row["producer_id"] if "producer_id" in columns else None,
494
  )
495
 
496
 
 
568
  )
569
 
570
 
571
+ def update_contact_last_interaction(session_id: str) -> None:
572
+ """
573
+ Update last_interaction timestamp for a contact session.
574
+
575
+ Feature: 001-contact-session-fixes
576
+
577
+ Called after adding facts or sending messages to keep the contact
578
+ sorted by most recent activity in the contact list.
579
+
580
+ Args:
581
+ session_id: Contact session ID
582
+
583
+ Raises:
584
+ NotFoundError: If session_id doesn't exist
585
+ """
586
+ conn = get_db_connection()
587
+ cursor = conn.cursor()
588
+
589
+ # Check if session exists
590
+ cursor.execute("SELECT * FROM contact_sessions WHERE session_id = ?", (session_id,))
591
+ existing = cursor.fetchone()
592
+ if not existing:
593
+ conn.close()
594
+ raise NotFoundError(f"Contact session {session_id} not found")
595
+
596
+ # Update last_interaction to current time
597
+ now = datetime.now()
598
+ cursor.execute(
599
+ "UPDATE contact_sessions SET last_interaction = ? WHERE session_id = ?",
600
+ (now, session_id)
601
+ )
602
+ conn.commit()
603
+ conn.close()
604
+
605
+
606
  def delete_contact_session(session_id: str) -> None:
607
  """
608
  Delete a contact session from SQLite.
src/templates/contacts/view.html CHANGED
@@ -2,6 +2,59 @@
2
 
3
  {% block title %}{{ contact.contact_name }} - PrepMate{% endblock %}
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  {% block content %}
6
  <div class="container mt-4">
7
  <!-- Back Button and Header -->
@@ -108,7 +161,7 @@
108
  <!-- Assistant Message (AI response) - Right aligned, gray background -->
109
  <div class="d-flex justify-content-end">
110
  <div class="bg-light border p-3 rounded" style="max-width: 75%;">
111
- <p class="mb-1 text-dark">{{ message.get('content', message.get('message', '')) }}</p>
112
  <small class="text-muted">
113
  <i class="bi bi-cpu"></i> AI · {{ message.get('created_at', '') }}
114
  </small>
@@ -128,9 +181,13 @@
128
  </div>
129
  {% endfor %}
130
  {% else %}
131
- <div class="empty-state h-100 d-flex flex-column justify-content-center align-items-center">
132
- <i class="bi bi-chat-left-quote" style="font-size: 3rem; color: #6c757d;"></i>
133
- <p class="mt-3 text-muted">No messages yet. Add facts or ask questions to start!</p>
 
 
 
 
134
  </div>
135
  {% endif %}
136
  </div>
@@ -335,6 +392,16 @@ if (editContactForm) {
335
  });
336
  }
337
 
 
 
 
 
 
 
 
 
 
 
338
  // Chat functionality
339
  const messageHistory = document.getElementById('messageHistory');
340
  const messageContent = document.getElementById('messageContent');
 
2
 
3
  {% block title %}{{ contact.contact_name }} - PrepMate{% endblock %}
4
 
5
+ {% block head %}
6
+ <!-- Marked.js for Markdown rendering -->
7
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"></script>
8
+ <style>
9
+ /* Markdown styling for assistant messages */
10
+ .markdown-content {
11
+ line-height: 1.6;
12
+ }
13
+ .markdown-content p {
14
+ margin-bottom: 0.75rem;
15
+ }
16
+ .markdown-content p:last-child {
17
+ margin-bottom: 0;
18
+ }
19
+ .markdown-content ul, .markdown-content ol {
20
+ margin-bottom: 0.75rem;
21
+ padding-left: 1.5rem;
22
+ }
23
+ .markdown-content code {
24
+ background-color: rgba(0,0,0,0.05);
25
+ padding: 0.2rem 0.4rem;
26
+ border-radius: 0.25rem;
27
+ font-size: 0.9em;
28
+ }
29
+ .markdown-content pre {
30
+ background-color: rgba(0,0,0,0.05);
31
+ padding: 0.75rem;
32
+ border-radius: 0.25rem;
33
+ overflow-x: auto;
34
+ margin-bottom: 0.75rem;
35
+ }
36
+ .markdown-content pre code {
37
+ background-color: transparent;
38
+ padding: 0;
39
+ }
40
+ .markdown-content blockquote {
41
+ border-left: 3px solid #dee2e6;
42
+ padding-left: 1rem;
43
+ margin-left: 0;
44
+ color: #6c757d;
45
+ }
46
+ .markdown-content h1, .markdown-content h2, .markdown-content h3,
47
+ .markdown-content h4, .markdown-content h5, .markdown-content h6 {
48
+ margin-top: 1rem;
49
+ margin-bottom: 0.5rem;
50
+ font-weight: 600;
51
+ }
52
+ .markdown-content h1 { font-size: 1.5rem; }
53
+ .markdown-content h2 { font-size: 1.3rem; }
54
+ .markdown-content h3 { font-size: 1.1rem; }
55
+ </style>
56
+ {% endblock %}
57
+
58
  {% block content %}
59
  <div class="container mt-4">
60
  <!-- Back Button and Header -->
 
161
  <!-- Assistant Message (AI response) - Right aligned, gray background -->
162
  <div class="d-flex justify-content-end">
163
  <div class="bg-light border p-3 rounded" style="max-width: 75%;">
164
+ <div class="markdown-content text-dark" data-markdown-content>{{ message.get('content', message.get('message', '')) }}</div>
165
  <small class="text-muted">
166
  <i class="bi bi-cpu"></i> AI · {{ message.get('created_at', '') }}
167
  </small>
 
181
  </div>
182
  {% endfor %}
183
  {% else %}
184
+ <!-- Feature: 001-contact-session-fixes - Empty state placeholder -->
185
+ <div class="h-100 d-flex flex-column justify-content-center align-items-center p-4">
186
+ <div class="alert alert-info" role="status" style="max-width: 500px;">
187
+ <i class="bi bi-info-circle me-2"></i>
188
+ <strong>No facts or messages yet.</strong>
189
+ <p class="mb-0 mt-2 small">Add your first fact about {{ contact.contact_name }} using the "Add Fact" button below, or ask a question to start a conversation.</p>
190
+ </div>
191
  </div>
192
  {% endif %}
193
  </div>
 
392
  });
393
  }
394
 
395
+ // Render Markdown in assistant messages
396
+ document.addEventListener('DOMContentLoaded', function() {
397
+ const markdownElements = document.querySelectorAll('[data-markdown-content]');
398
+ markdownElements.forEach(function(element) {
399
+ const rawContent = element.textContent;
400
+ const renderedMarkdown = marked.parse(rawContent);
401
+ element.innerHTML = renderedMarkdown;
402
+ });
403
+ });
404
+
405
  // Chat functionality
406
  const messageHistory = document.getElementById('messageHistory');
407
  const messageContent = document.getElementById('messageContent');
src/utils/__pycache__/contact_utils.cpython-311.pyc CHANGED
Binary files a/src/utils/__pycache__/contact_utils.cpython-311.pyc and b/src/utils/__pycache__/contact_utils.cpython-311.pyc differ
 
src/utils/__pycache__/tracing.cpython-311.pyc CHANGED
Binary files a/src/utils/__pycache__/tracing.cpython-311.pyc and b/src/utils/__pycache__/tracing.cpython-311.pyc differ