Spaces:
Running
Running
Christian Kniep
commited on
Commit
·
dc4561e
1
Parent(s):
ea1a26b
update to MVP
Browse files- Dockerfile +4 -1
- entrypoint.sh +96 -0
- migrations/002_add_producer_columns.sql +30 -0
- src/__pycache__/app.cpython-311.pyc +0 -0
- src/models/__pycache__/__init__.cpython-311.pyc +0 -0
- src/routes/__pycache__/auth.cpython-311.pyc +0 -0
- src/routes/__pycache__/contacts.cpython-311.pyc +0 -0
- src/routes/__pycache__/profile.cpython-311.pyc +0 -0
- src/routes/auth.py +1 -0
- src/routes/contacts.py +108 -48
- src/routes/profile.py +4 -3
- src/services/__pycache__/auth_service.cpython-311.pyc +0 -0
- src/services/__pycache__/backend_client.cpython-311.pyc +0 -0
- src/services/__pycache__/storage_service.cpython-311.pyc +0 -0
- src/services/backend_client.py +101 -9
- src/services/storage_service.py +49 -6
- src/templates/contacts/view.html +71 -4
- src/utils/__pycache__/contact_utils.cpython-311.pyc +0 -0
- src/utils/__pycache__/tracing.cpython-311.pyc +0 -0
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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 382 |
-
|
| 383 |
-
|
| 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.
|
| 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 |
-
#
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 462 |
-
produced_for=user_id,
|
|
|
|
| 463 |
)
|
| 464 |
|
| 465 |
# Update last interaction timestamp
|
| 466 |
-
storage_service.
|
|
|
|
|
|
|
|
|
|
| 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
|
| 442 |
-
sequence_number=row
|
| 443 |
-
producer_id=row
|
| 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
|
| 484 |
-
sequence_number=row
|
| 485 |
-
producer_id=row
|
| 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 |
-
<
|
| 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 |
-
|
| 132 |
-
|
| 133 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|