', unsafe_allow_html=True)
-
- for session in regular_sessions:
- render_session_row(session, active_session_id, user_id)
-
- # Empty state
- if not reference_sessions and not regular_sessions:
- st.markdown('
No sessions yet. Click "New Session" to start!
',
- unsafe_allow_html=True)
-
-
-def render_sidebar(user_id: Optional[str] = None):
- """
- Main sidebar rendering function.
-
- Renders:
- 1. User header (avatar + username)
- 2. New Session button
- 3. Session list (reference + regular sections)
-
- Args:
- user_id: Optional user ID (defaults to session state or environment)
- """
- # Load CSS
- load_sidebar_css()
-
- # Get user ID from session state or parameter
- if not user_id:
- user_id = st.session_state.get("user_id", "demo_user")
-
- with st.sidebar:
- # Render user header
- user_profile = get_user_profile(user_id)
- if user_profile:
- render_user_header(user_profile)
-
- # Render New Session button
- render_new_session_button(user_id)
-
- # Fetch sessions
- try:
- sessions = get_sessions_list(user_id)
-
- # Get active session ID
- active_session_id = st.session_state.get("active_session_id")
-
- # Render session list
- render_session_table(sessions, active_session_id, user_id)
-
- except Exception as e:
- log_error("render_sidebar", e)
- st.error("Failed to load sessions")
diff --git a/src/ui/pages/__init__.py b/src/ui/pages/__init__.py
deleted file mode 100644
index 180f0de5baa53cb4d7cc602bed56e533e1510241..0000000000000000000000000000000000000000
--- a/src/ui/pages/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""UI pages package."""
diff --git a/src/ui/pages/main.py b/src/ui/pages/main.py
deleted file mode 100644
index 39b4eff45057e98ae3e8c0ec0a39b17f9e8b41c6..0000000000000000000000000000000000000000
--- a/src/ui/pages/main.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""
-Main page for API Session Chat Frontend.
-
-Integrates session list sidebar and main chat interface with OAuth authentication.
-"""
-
-import streamlit as st
-
-from src.ui.components.sidebar import render_sidebar
-from src.ui.components.chat import render_chat
-from src.services.session_manager import get_session
-from src.ui.auth import show_login_button, handle_oauth_redirect
-
-
-def render():
- """Render the main application page."""
-
- # Initialize user authentication state
- if "user_id" not in st.session_state:
- st.session_state["user_id"] = None
- st.session_state["user_name"] = None
- st.session_state["user_email"] = None
- st.session_state["user_avatar"] = None
- st.session_state["access_token"] = None
-
- # Check if we're handling an OAuth callback
- if handle_oauth_redirect():
- # OAuth callback was processed (successfully or with error)
- # Page will reload after authentication
- return
-
- # Check if user is authenticated
- if st.session_state["user_id"] is None:
- # User not authenticated - show login button
- show_login_button()
- st.stop()
-
- # User is authenticated - continue with app
- _render_authenticated_app()
-
-
-def _render_authenticated_app():
- """Render the main app for authenticated users."""
-
- # Initialize session state
- if "active_session_id" not in st.session_state:
- st.session_state["active_session_id"] = None
-
- # Render new compact sidebar with session list
- render_sidebar(user_id=st.session_state.get("user_id"))
-
- # Main content area
- active_session_id = st.session_state.get("active_session_id")
-
- if not active_session_id:
- # No session selected
- st.title("Welcome to API Session Chat")
- st.markdown("""
- ### Getting Started
-
- 1. **Create a session** using the sidebar (←)
- 2. **Select a session** to start chatting
- 3. **Send messages** in different modes:
- - 🔗 **URL**: Provide a URL for the server to parse
- - 📝 **Fact**: Store information about your topic
- - ❓ **Query**: Ask questions based on stored knowledge
-
- ### Features
-
- - **Session Management**: Create, switch between, and delete sessions
- - **Message Modes**: URL parsing, fact storage, and intelligent queries
- - **Chat History**: All messages are persisted across app restarts
- - **Reference Sessions**: Compare sessions to find overlaps
-
- 👈 **Start by creating a session in the sidebar!**
- """)
- else:
- # Load active session
- session = get_session(active_session_id)
-
- if not session:
- st.error(f"Session {active_session_id} not found. Please select another session.")
- st.session_state["active_session_id"] = None
- st.rerun()
- return
-
- # Render chat interface
- render_chat(session_id=active_session_id, session_name=session.name)
diff --git a/src/ui/styles/sidebar.css b/src/ui/styles/sidebar.css
deleted file mode 100644
index bb383b29317fce63428118c239418f977b0cc47b..0000000000000000000000000000000000000000
--- a/src/ui/styles/sidebar.css
+++ /dev/null
@@ -1,292 +0,0 @@
-/* Sidebar Styles for Compact Session UI */
-
-/* User Header Section */
-.sidebar-user-header {
- display: flex;
- align-items: center;
- padding: 8px 12px;
- background-color: #f0f2f6;
- border-radius: 8px;
- margin-bottom: 12px;
- max-height: 60px;
- border: 1px solid #e0e0e0;
-}
-
-.sidebar-user-avatar {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- margin-right: 10px;
- flex-shrink: 0;
-}
-
-.sidebar-user-info {
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.sidebar-username {
- font-weight: 600;
- font-size: 14px;
- color: #262730;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-/* New Session Input and Button */
-.sidebar-new-session-container {
- display: flex;
- gap: 8px;
- margin-bottom: 16px;
- align-items: center;
-}
-
-.sidebar-new-session-input {
- flex: 1;
- padding: 8px 12px;
- border: 1px solid #e0e0e0;
- border-radius: 6px;
- font-size: 13px;
-}
-
-.sidebar-new-session-input:focus {
- outline: none;
- border-color: #ff4b4b;
-}
-
-.sidebar-new-session-btn {
- padding: 8px 12px;
- background-color: #ff4b4b;
- color: white;
- border: none;
- border-radius: 6px;
- font-size: 16px;
- font-weight: 600;
- cursor: pointer;
- transition: background-color 0.2s;
- flex-shrink: 0;
-}
-
-.sidebar-new-session-btn:hover {
- background-color: #ff3333;
-}
-
-.sidebar-new-session-btn:active {
- background-color: #ff2222;
-}
-
-/* Session List Section Headers */
-.session-section-header {
- font-size: 12px;
- font-weight: 700;
- color: #808495;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin-top: 16px;
- margin-bottom: 8px;
- padding: 0 8px;
-}
-
-/* Session Table */
-.session-table {
- width: 100%;
- border-collapse: collapse;
- margin-bottom: 12px;
-}
-
-.session-row {
- height: 40px;
- border-bottom: 1px solid #e0e0e0;
- cursor: pointer;
- transition: background-color 0.15s;
-}
-
-.session-row:hover {
- background-color: #f5f7f9;
-}
-
-.session-row.active {
- background-color: #e6f2ff;
- border-left: 3px solid #ff4b4b;
-}
-
-.session-row td {
- padding: 4px 8px;
- vertical-align: middle;
- font-size: 13px;
-}
-
-/* Reference Star Column */
-.session-star {
- width: 30px;
- text-align: center;
- font-size: 18px;
- cursor: pointer;
- user-select: none;
-}
-
-.session-star.filled {
- color: #ffd700;
-}
-
-.session-star.outline {
- color: #c0c0c0;
-}
-
-.session-star:hover {
- transform: scale(1.2);
-}
-
-/* Streamlit star button styling - ensure minimum size */
-[data-testid="column"] button[kind="secondary"] {
- min-width: 80px !important;
- min-height: 40px !important;
- padding: 8px !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
-}
-
-/* Star button specific - larger font for proper display */
-[data-testid="column"]:first-child button[kind="secondary"] {
- font-size: 18px !important;
- line-height: 1 !important;
-}
-
-/* Session Name Column */
-.session-name {
- max-width: 150px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- font-weight: 500;
- color: #262730;
-}
-
-.session-name:hover {
- color: #ff4b4b;
-}
-
-/* Last Interaction Column */
-.session-time {
- font-size: 11px;
- color: #808495;
- white-space: nowrap;
-}
-
-/* Burger Menu Column */
-.session-menu {
- width: 30px;
- text-align: center;
- font-size: 18px;
- cursor: pointer;
- color: #808495;
-}
-
-.session-menu:hover {
- color: #262730;
-}
-
-/* Reference Sessions Section Visual Distinction */
-.reference-section {
- background-color: #fff8e1;
- border: 1px solid #ffd54f;
- border-radius: 8px;
- padding: 8px;
- margin-bottom: 12px;
-}
-
-.reference-section .session-row {
- background-color: transparent;
-}
-
-.reference-section .session-row:hover {
- background-color: #fff3cd;
-}
-
-/* Empty State */
-.session-empty-state {
- text-align: center;
- padding: 24px;
- color: #808495;
- font-size: 13px;
-}
-
-/* Scrollable Session List Container */
-.session-list-container {
- max-height: calc(100vh - 250px);
- overflow-y: auto;
- overflow-x: hidden;
- scrollbar-width: thin;
- scrollbar-color: #c0c0c0 #f0f0f0;
-}
-
-.session-list-container::-webkit-scrollbar {
- width: 6px;
-}
-
-.session-list-container::-webkit-scrollbar-track {
- background: #f0f0f0;
-}
-
-.session-list-container::-webkit-scrollbar-thumb {
- background-color: #c0c0c0;
- border-radius: 3px;
-}
-
-.session-list-container::-webkit-scrollbar-thumb:hover {
- background-color: #a0a0a0;
-}
-
-/* Tooltip Styles */
-.session-tooltip {
- position: relative;
- display: inline-block;
-}
-
-.session-tooltip .tooltiptext {
- visibility: hidden;
- width: 200px;
- background-color: #262730;
- color: #fff;
- text-align: center;
- border-radius: 6px;
- padding: 6px 8px;
- position: absolute;
- z-index: 1000;
- bottom: 125%;
- left: 50%;
- margin-left: -100px;
- opacity: 0;
- transition: opacity 0.3s;
- font-size: 12px;
-}
-
-.session-tooltip:hover .tooltiptext {
- visibility: visible;
- opacity: 1;
-}
-
-/* Loading Spinner */
-.sidebar-loading {
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 20px;
-}
-
-.spinner {
- border: 3px solid #f3f3f3;
- border-top: 3px solid #ff4b4b;
- border-radius: 50%;
- width: 30px;
- height: 30px;
- animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
deleted file mode 100644
index c073f297cf6242ac18a5f31f6a2cb5bd3086238c..0000000000000000000000000000000000000000
--- a/src/utils/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Utils package for API Session Chat Frontend."""
diff --git a/src/utils/__pycache__/contact_utils.cpython-311.pyc b/src/utils/__pycache__/contact_utils.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..daf9146e1dc9cbd0aac5a3ac746510f677b25198
Binary files /dev/null and b/src/utils/__pycache__/contact_utils.cpython-311.pyc differ
diff --git a/src/utils/__pycache__/contact_utils.cpython-313.pyc b/src/utils/__pycache__/contact_utils.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ae0babb839b0bd5b3641f91fd32147dfb9f4c609
Binary files /dev/null and b/src/utils/__pycache__/contact_utils.cpython-313.pyc differ
diff --git a/src/utils/__pycache__/tracing.cpython-311.pyc b/src/utils/__pycache__/tracing.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0ca822855927dc4f956a2ca0614988a1f7726fca
Binary files /dev/null and b/src/utils/__pycache__/tracing.cpython-311.pyc differ
diff --git a/src/utils/config.py b/src/utils/config.py
deleted file mode 100644
index 5bb4715fce3f9c5b7bb82af49852366b74e04e2f..0000000000000000000000000000000000000000
--- a/src/utils/config.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""
-Configuration module for API Session Chat Frontend.
-
-Handles environment variables and Streamlit secrets for API server configuration.
-"""
-
-import os
-import streamlit as st
-
-
-def get_api_base_url() -> str:
- """
- Get the API server base URL from environment or secrets.
-
- Priority:
- 1. Environment variable API_SERVER_URL (production)
- 2. Streamlit secrets api_server_url (local development)
- 3. Default localhost:8000 (fallback)
-
- Returns:
- str: Base URL for the API server (e.g., "http://localhost:8000")
- """
- # Prefer environment variable (production/HF Spaces)
- if "API_SERVER_URL" in os.environ:
- return os.environ["API_SERVER_URL"].rstrip("/")
-
- # Fall back to Streamlit secrets (local dev)
- if hasattr(st, "secrets") and "api_server_url" in st.secrets:
- return st.secrets["api_server_url"].rstrip("/")
-
- # Default for local testing (updated to port 4004)
- return "http://localhost:4004"
-
-
-# Memory Backend Configuration
-MEMORY_BACKEND_URL: str = os.getenv(
- "MEMORY_BACKEND_URL", "http://localhost:8082"
-)
-"""Base URL for the episodic memory backend API."""
-
-MEMORY_BACKEND_TIMEOUT: int = int(os.getenv("MEMORY_BACKEND_TIMEOUT", "20"))
-"""Request timeout in seconds for memory backend API calls."""
-
-MEMORY_BACKEND_ENABLED: bool = (
- os.getenv("MEMORY_BACKEND_ENABLED", "true").lower() == "true"
-)
-"""Feature flag to enable/disable memory backend integration."""
-
-MEMORY_CACHE_TTL: int = int(os.getenv("MEMORY_CACHE_TTL", "1800"))
-"""Cache time-to-live in seconds (default: 1800 = 30 minutes)."""
-
-MEMORY_MAX_MESSAGES: int = int(os.getenv("MEMORY_MAX_MESSAGES", "50"))
-"""Maximum number of messages to retrieve per session."""
-
-
-def get_memory_backend_url() -> str:
- """
- Get the memory backend URL if enabled.
-
- Returns:
- Memory backend URL if enabled, empty string otherwise.
- """
- if MEMORY_BACKEND_ENABLED:
- return MEMORY_BACKEND_URL
- return ""
-
-
-def is_memory_backend_enabled() -> bool:
- """
- Check if memory backend integration is enabled.
-
- Returns:
- True if memory backend is enabled, False otherwise.
- """
- return MEMORY_BACKEND_ENABLED
diff --git a/src/utils/contact_utils.py b/src/utils/contact_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..6080a098af03a9ad12df7c9d11b515c998b35afe
--- /dev/null
+++ b/src/utils/contact_utils.py
@@ -0,0 +1,44 @@
+"""
+Contact name normalization utilities for producer identifier generation.
+
+This module provides functions to normalize contact names for use in producer
+identifiers, ensuring consistent handling of special characters and collisions.
+"""
+
+import re
+
+
+def normalize_contact_name(name: str) -> str:
+ """
+ Normalize contact name for producer identifier.
+
+ Converts to lowercase and removes all non-alphanumeric characters,
+ keeping only letters and digits. This enables consistent producer
+ identifiers while allowing collisions to be handled by sequence numbers.
+
+ Args:
+ name: Contact name to normalize (e.g., "John O'Brien")
+
+ Returns:
+ Normalized name with only lowercase alphanumeric characters (e.g., "johnobrien")
+
+ Examples:
+ >>> normalize_contact_name("John Smith")
+ 'johnsmith'
+ >>> normalize_contact_name("O'Brien")
+ 'obrien'
+ >>> normalize_contact_name("Mary-Ann")
+ 'maryann'
+ >>> normalize_contact_name("María García")
+ 'maríagarcía'
+ >>> normalize_contact_name("李明")
+ '李明'
+ >>> normalize_contact_name("John (Johnny) Smith")
+ 'johnjohnnysmith'
+ """
+ if not name:
+ return ""
+
+ # Convert to lowercase and remove all non-alphanumeric characters
+ # This regex keeps unicode letters/digits but removes spaces, punctuation, special chars
+ return re.sub(r'[^a-z0-9]', '', name.lower())
diff --git a/src/utils/logging.py b/src/utils/logging.py
deleted file mode 100644
index f0a8c5e6d403158864906ea9fa183529a53a5343..0000000000000000000000000000000000000000
--- a/src/utils/logging.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-Structured logging utility for API Session Chat Frontend.
-
-Provides JSON-formatted logging with context.
-
-Events used by UI/UX improvements (010-ui-ux-improvements):
-- mode_changed: User switches between Assistant and Memorizing Assistant modes
-- memory_save: Message saved to memory backend (success/error)
-- header_rendered: Session header rendered with metadata
-"""
-
-import json
-import logging
-from datetime import datetime
-from typing import Optional
-
-
-# Configure logging
-logging.basicConfig(
- level=logging.INFO,
- format='%(message)s'
-)
-
-logger = logging.getLogger("api_session_chat")
-
-
-def log_event(
- action: str,
- status: str,
- session_id: Optional[str] = None,
- duration: Optional[float] = None,
- **kwargs
-) -> None:
- """
- Log a structured event in JSON format.
-
- Args:
- action: Action being performed (e.g., "create_session", "send_message")
- status: Status of the action ("success", "error", "pending")
- session_id: Optional session identifier
- duration: Optional duration in seconds
- **kwargs: Additional context fields
- """
- log_entry = {
- "timestamp": datetime.utcnow().isoformat() + "Z",
- "action": action,
- "status": status
- }
-
- if session_id:
- log_entry["session_id"] = session_id
-
- if duration is not None:
- log_entry["duration_seconds"] = round(duration, 3)
-
- # Add any additional context
- log_entry.update(kwargs)
-
- # Log as JSON
- logger.info(json.dumps(log_entry))
-
-
-def log_error(
- action: str,
- error: Exception,
- session_id: Optional[str] = None,
- **kwargs
-) -> None:
- """
- Log an error event.
-
- Args:
- action: Action that failed
- error: Exception that occurred
- session_id: Optional session identifier
- **kwargs: Additional context fields
- """
- log_event(
- action=action,
- status="error",
- session_id=session_id,
- error_type=type(error).__name__,
- error_message=str(error),
- **kwargs
- )
diff --git a/src/utils/memory_cache.py b/src/utils/memory_cache.py
deleted file mode 100644
index 8b6b6e88fd5071f13f1d77be7c202a2b8b237be6..0000000000000000000000000000000000000000
--- a/src/utils/memory_cache.py
+++ /dev/null
@@ -1,220 +0,0 @@
-"""
-Memory cache utilities for local session caching.
-
-This module provides functions for managing the local cache of episodic memories
-in Streamlit session_state, including TTL validation, cache updates, and invalidation.
-"""
-
-import streamlit as st
-from datetime import datetime, timedelta
-from typing import List, Dict, Any, Optional
-import logging
-
-logger = logging.getLogger(__name__)
-
-# Cache structure in session_state:
-# st.session_state['memory_cache'] = {
-# 'session_id_1': {
-# 'messages': [Message(...), Message(...)],
-# 'fetched_at': datetime,
-# 'last_accessed': datetime
-# }
-# }
-
-
-def _get_cache() -> Dict[str, Any]:
- """
- Get or initialize the memory cache in session_state.
-
- Returns:
- Dictionary containing cached session data.
- """
- if 'memory_cache' not in st.session_state:
- st.session_state['memory_cache'] = {}
- return st.session_state['memory_cache']
-
-
-def is_cache_valid(session_id: str, ttl_seconds: int = 1800) -> bool:
- """
- Check if the cache for a session is valid (not expired).
-
- Args:
- session_id: The session identifier to check.
- ttl_seconds: Time-to-live in seconds (default: 1800 = 30 minutes).
-
- Returns:
- True if cache exists and is within TTL, False otherwise.
- """
- cache = _get_cache()
-
- if session_id not in cache:
- logger.debug(f"Cache miss: session_id={session_id} not in cache")
- return False
-
- session_cache = cache[session_id]
- last_accessed = session_cache.get('last_accessed')
-
- if not last_accessed:
- logger.warning(f"Cache entry missing last_accessed: session_id={session_id}")
- return False
-
- age = datetime.now() - last_accessed
- is_valid = age.total_seconds() < ttl_seconds
-
- if not is_valid:
- logger.debug(
- f"Cache expired: session_id={session_id}, "
- f"age={age.total_seconds():.1f}s, ttl={ttl_seconds}s"
- )
- else:
- logger.debug(
- f"Cache hit: session_id={session_id}, "
- f"age={age.total_seconds():.1f}s"
- )
-
- return is_valid
-
-
-def get_cached_messages(session_id: str) -> Optional[List[Dict[str, Any]]]:
- """
- Retrieve cached messages for a session.
-
- Args:
- session_id: The session identifier.
-
- Returns:
- List of cached message dictionaries if available, None otherwise.
- """
- cache = _get_cache()
-
- if session_id not in cache:
- return None
-
- session_cache = cache[session_id]
-
- # Update last_accessed timestamp
- session_cache['last_accessed'] = datetime.now()
-
- messages = session_cache.get('messages', [])
- logger.info(
- f"Retrieved {len(messages)} messages from cache: session_id={session_id}"
- )
-
- return messages
-
-
-def update_cache(
- session_id: str,
- messages: List[Dict[str, Any]],
- append: bool = False
-) -> None:
- """
- Update the cache with new messages for a session.
-
- Args:
- session_id: The session identifier.
- messages: List of message dictionaries to cache.
- append: If True, append to existing messages; if False, replace (default).
- """
- cache = _get_cache()
- now = datetime.now()
-
- if session_id in cache and append:
- # Append new messages to existing cache
- existing_messages = cache[session_id].get('messages', [])
- combined_messages = existing_messages + messages
- cache[session_id] = {
- 'messages': combined_messages,
- 'fetched_at': cache[session_id].get('fetched_at', now),
- 'last_accessed': now
- }
- logger.info(
- f"Appended {len(messages)} messages to cache "
- f"(total: {len(combined_messages)}): session_id={session_id}"
- )
- else:
- # Replace cache with new messages
- cache[session_id] = {
- 'messages': messages,
- 'fetched_at': now,
- 'last_accessed': now
- }
- logger.info(
- f"Cached {len(messages)} messages: session_id={session_id}"
- )
-
-
-def clear_cache(session_id: Optional[str] = None) -> None:
- """
- Clear the cache for a specific session or all sessions.
-
- Args:
- session_id: The session identifier to clear, or None to clear all.
- """
- cache = _get_cache()
-
- if session_id is None:
- # Clear all caches
- count = len(cache)
- cache.clear()
- logger.info(f"Cleared all cached sessions: {count} sessions removed")
- elif session_id in cache:
- # Clear specific session
- del cache[session_id]
- logger.info(f"Cleared cache: session_id={session_id}")
- else:
- logger.debug(f"Cache clear requested for non-existent session: {session_id}")
-
-
-def invalidate_cache(session_id: str) -> None:
- """
- Invalidate (remove) the cache for a specific session.
-
- This is an alias for clear_cache(session_id) for clarity.
-
- Args:
- session_id: The session identifier to invalidate.
- """
- clear_cache(session_id)
- logger.debug(f"Cache invalidated: session_id={session_id}")
-
-
-def get_cache_stats() -> Dict[str, Any]:
- """
- Get statistics about the current cache state.
-
- Returns:
- Dictionary with cache statistics (session count, total messages, etc.).
- """
- cache = _get_cache()
-
- total_sessions = len(cache)
- total_messages = sum(
- len(session_data.get('messages', []))
- for session_data in cache.values()
- )
-
- stats = {
- 'total_sessions': total_sessions,
- 'total_messages': total_messages,
- 'sessions': []
- }
-
- for session_id, session_data in cache.items():
- message_count = len(session_data.get('messages', []))
- fetched_at = session_data.get('fetched_at')
- last_accessed = session_data.get('last_accessed')
-
- age_seconds = None
- if last_accessed:
- age_seconds = (datetime.now() - last_accessed).total_seconds()
-
- stats['sessions'].append({
- 'session_id': session_id,
- 'message_count': message_count,
- 'fetched_at': fetched_at.isoformat() if fetched_at else None,
- 'last_accessed': last_accessed.isoformat() if last_accessed else None,
- 'age_seconds': age_seconds
- })
-
- return stats
diff --git a/src/utils/oauth_utils.py b/src/utils/oauth_utils.py
deleted file mode 100644
index 66f3eeb42eb3318e735d89473a51cf7d7d031976..0000000000000000000000000000000000000000
--- a/src/utils/oauth_utils.py
+++ /dev/null
@@ -1,330 +0,0 @@
-"""OAuth utility functions for HuggingFace Spaces OAuth integration.
-
-This module provides utilities for the OAuth 2.0 authorization code flow with HuggingFace.
-It handles the redirect-based authentication flow including state verification and token exchange.
-
-Functions:
- get_oauth_authorize_url(redirect_uri: str) -> str: Generate OAuth authorization URL with state
- handle_oauth_callback(code: str, state: str, redirect_uri: str) -> Optional[Dict]: Handle OAuth callback
- get_user_info_from_token(access_token: str) -> Optional[Dict]: Fetch user info using access token
- is_authenticated() -> bool: Check if user is authenticated
-
-Environment Variables:
- OAUTH_CLIENT_ID: OAuth client ID (provided by HF Spaces)
- OAUTH_CLIENT_SECRET: OAuth client secret (provided by HF Spaces)
- SPACE_HOST: Space hostname (provided by HF Spaces)
- MOCK_OAUTH_ENABLED: Set to "true" for local development
- MOCK_OAUTH_USER_ID: Mock user ID for local dev
-"""
-
-import os
-import logging
-import secrets
-import base64
-from typing import Optional, Dict
-import streamlit as st
-import requests
-
-logger = logging.getLogger(__name__)
-
-# OAuth endpoints
-OAUTH_AUTHORIZE_URL = "https://huggingface.co/oauth/authorize"
-OAUTH_TOKEN_URL = "https://huggingface.co/oauth/token"
-OAUTH_USERINFO_URL = "https://huggingface.co/oauth/userinfo"
-
-
-def get_oauth_authorize_url(redirect_uri: str, scopes: str = "openid profile") -> tuple[str, str]:
- """Generate OAuth authorization URL with state parameter.
-
- Args:
- redirect_uri: Callback URL (e.g., https://{SPACE_HOST}/login/callback)
- scopes: Space-separated OAuth scopes (default: "openid profile")
-
- Returns:
- Tuple of (authorization_url, state) where state should be stored for verification
-
- Examples:
- >>> auth_url, state = get_oauth_authorize_url("https://myspace.hf.space/login/callback")
- >>> st.session_state["oauth_state"] = state
- >>> st.markdown(f'Sign in with HuggingFace')
- """
- client_id = os.getenv("OAUTH_CLIENT_ID")
- if not client_id:
- raise ValueError("OAUTH_CLIENT_ID environment variable not set")
-
- # Generate random state for CSRF protection
- state = secrets.token_urlsafe(32)
-
- # Build authorization URL
- params = {
- "client_id": client_id,
- "redirect_uri": redirect_uri,
- "scope": scopes,
- "state": state,
- "response_type": "code"
- }
-
- query_string = "&".join([f"{k}={requests.utils.quote(str(v))}" for k, v in params.items()])
- auth_url = f"{OAUTH_AUTHORIZE_URL}?{query_string}"
-
- logger.info(f"Generated OAuth authorization URL with state={state[:8]}...")
- return auth_url, state
-
-
-def handle_oauth_callback(code: str, state: str, redirect_uri: str) -> Optional[Dict[str, str]]:
- """Handle OAuth callback by exchanging code for tokens and fetching user info.
-
- Args:
- code: Authorization code from query parameter
- state: State from query parameter (must match stored state)
- redirect_uri: Same redirect URI used in authorization request
-
- Returns:
- User identity dictionary with keys: id, name, email, avatar, access_token
- Returns None if authentication fails
-
- Examples:
- >>> code = st.query_params.get("code")
- >>> state = st.query_params.get("state")
- >>> stored_state = st.session_state.get("oauth_state")
- >>> if state == stored_state:
- ... user = handle_oauth_callback(code, state, redirect_uri)
- """
- try:
- # Verify state parameter (CSRF protection)
- stored_state = st.session_state.get("oauth_state")
- if not stored_state or state != stored_state:
- logger.error(f"State mismatch: got {state[:8]}... expected {stored_state[:8] if stored_state else 'None'}...")
- return None
-
- # Get client credentials
- client_id = os.getenv("OAUTH_CLIENT_ID")
- client_secret = os.getenv("OAUTH_CLIENT_SECRET")
-
- if not client_id or not client_secret:
- logger.error("OAUTH_CLIENT_ID or OAUTH_CLIENT_SECRET not set")
- return None
-
- # Exchange authorization code for access token
- # Using Basic Authentication as per HuggingFace OAuth spec
- credentials = f"{client_id}:{client_secret}"
- encoded_credentials = base64.b64encode(credentials.encode()).decode()
-
- token_response = requests.post(
- OAUTH_TOKEN_URL,
- data={
- "client_id": client_id,
- "code": code,
- "grant_type": "authorization_code",
- "redirect_uri": redirect_uri
- },
- headers={
- "Authorization": f"Basic {encoded_credentials}",
- "Content-Type": "application/x-www-form-urlencoded"
- },
- timeout=10
- )
-
- if token_response.status_code != 200:
- logger.error(f"Token exchange failed: {token_response.status_code} {token_response.text}")
- return None
-
- token_data = token_response.json()
- access_token = token_data.get("access_token")
-
- if not access_token:
- logger.error("No access token in response")
- return None
-
- # Fetch user info using access token
- user_info = get_user_info_from_token(access_token)
- if not user_info:
- return None
-
- # Add access token to user info
- user_info["access_token"] = access_token
-
- logger.info(f"OAuth authentication successful for user: {user_info.get('name')} ({user_info.get('id')})")
- return user_info
-
- except Exception as e:
- logger.error(f"Error handling OAuth callback: {e}")
- return None
-
-
-def get_user_info_from_token(access_token: str) -> Optional[Dict[str, str]]:
- """Fetch user information using OAuth access token.
-
- Args:
- access_token: OAuth access token from token exchange
-
- Returns:
- User identity dictionary with keys: id, name, email, avatar
- Returns None if request fails
- """
- try:
- userinfo_response = requests.get(
- OAUTH_USERINFO_URL,
- headers={"Authorization": f"Bearer {access_token}"},
- timeout=10
- )
-
- if userinfo_response.status_code != 200:
- logger.error(f"Userinfo request failed: {userinfo_response.status_code}")
- return None
-
- userinfo = userinfo_response.json()
-
- # Extract user identity
- user_info = {
- "id": userinfo.get("preferred_username") or userinfo.get("sub"),
- "name": userinfo.get("name", userinfo.get("preferred_username", "Unknown")),
- "email": userinfo.get("email"),
- "avatar": userinfo.get("picture", "https://huggingface.co/avatars/default.png")
- }
-
- return user_info
-
- except Exception as e:
- logger.error(f"Error fetching user info: {e}")
- return None
-
-
-def get_mock_user_identity() -> Optional[Dict[str, str]]:
- """Get mock user identity for local development.
-
- Returns:
- Mock user identity dictionary or None if MOCK_OAUTH_USER_ID not set
- """
- user_id = os.getenv("MOCK_OAUTH_USER_ID")
- if not user_id:
- logger.warning("MOCK_OAUTH_ENABLED is true but MOCK_OAUTH_USER_ID is not set")
- return None
-
- return {
- "id": user_id,
- "name": os.getenv("MOCK_OAUTH_USER_NAME", user_id),
- "email": os.getenv("MOCK_OAUTH_USER_EMAIL"),
- "avatar": os.getenv("MOCK_OAUTH_USER_AVATAR", "https://huggingface.co/avatars/default.png"),
- "access_token": "mock_token_" + secrets.token_urlsafe(16)
- }
-
-
-def is_authenticated() -> bool:
- """Check if user is currently authenticated.
-
- Returns:
- True if user identity is available in session state, False otherwise.
-
- Examples:
- >>> if is_authenticated():
- ... st.write("Welcome back!")
- ... else:
- ... show_login_button()
- """
- return st.session_state.get("user_id") is not None
-
-import os
-import logging
-from typing import Optional, Dict
-import streamlit as st
-
-# Configure logging
-logger = logging.getLogger(__name__)
-
-
-def get_user_identity() -> Optional[Dict[str, str]]:
- """
- Extract user identity from HuggingFace OAuth headers.
-
- When deployed on HuggingFace Spaces with OAuth enabled, this function
- extracts the user's identity from the request headers. For local development,
- it supports mock OAuth via environment variables.
-
- Returns:
- Dictionary with user identity fields if authenticated:
- - id: HuggingFace username (unique identifier)
- - name: Display name
- - email: Email address (if shared)
- - avatar: Profile picture URL
-
- None if user is not authenticated.
-
- Example:
- >>> user = get_user_identity()
- >>> if user:
- ... st.write(f"Welcome, {user['name']}!")
- """
- try:
- # Check for mock OAuth in local development
- if os.getenv("MOCK_OAUTH_ENABLED", "").lower() == "true":
- return _get_mock_user_identity()
-
- # Extract OAuth headers from Streamlit context
- headers = st.context.headers
- user_id = headers.get("X-Oauth-Preferred-Username")
-
- if not user_id:
- logger.debug("No OAuth user_id found in headers")
- return None
-
- # Build user identity from OAuth headers
- user_identity = {
- "id": user_id,
- "name": headers.get("X-Oauth-Name", user_id),
- "email": headers.get("X-Oauth-Email"),
- "avatar": headers.get(
- "X-Oauth-Picture",
- "https://huggingface.co/avatars/default.png"
- )
- }
-
- logger.info(f"OAuth user authenticated: {user_id}")
- return user_identity
-
- except Exception as e:
- logger.error(f"Error extracting user identity: {e}", exc_info=True)
- return None
-
-
-def _get_mock_user_identity() -> Optional[Dict[str, str]]:
- """
- Get mock user identity for local development.
-
- Reads mock OAuth credentials from environment variables:
- - MOCK_OAUTH_USER_ID: Username (required)
- - MOCK_OAUTH_USER_NAME: Display name (optional)
- - MOCK_OAUTH_USER_EMAIL: Email (optional)
- - MOCK_OAUTH_USER_AVATAR: Avatar URL (optional)
-
- Returns:
- Dictionary with mock user identity or None if not configured.
- """
- user_id = os.getenv("MOCK_OAUTH_USER_ID")
-
- if not user_id:
- logger.warning("MOCK_OAUTH_ENABLED=true but MOCK_OAUTH_USER_ID not set")
- return None
-
- mock_identity = {
- "id": user_id,
- "name": os.getenv("MOCK_OAUTH_USER_NAME", user_id),
- "email": os.getenv("MOCK_OAUTH_USER_EMAIL"),
- "avatar": os.getenv(
- "MOCK_OAUTH_USER_AVATAR",
- "https://huggingface.co/avatars/default.png"
- )
- }
-
- logger.info(f"Mock OAuth user: {user_id} (local development)")
- return mock_identity
-
-
-def is_authenticated() -> bool:
- """
- Check if a user is currently authenticated.
-
- Returns:
- True if user identity can be extracted, False otherwise.
- """
- return get_user_identity() is not None
diff --git a/src/utils/tracing.py b/src/utils/tracing.py
new file mode 100644
index 0000000000000000000000000000000000000000..da4e6126b432a4868c249647cab043b3b4d13a6d
--- /dev/null
+++ b/src/utils/tracing.py
@@ -0,0 +1,80 @@
+"""
+Jaeger tracing utilities using OpenTelemetry.
+
+Uses OpenTelemetry OTLP HTTP exporter to send traces to Jaeger.
+This matches the Go API approach of using HTTP instead of unreliable UDP.
+"""
+
+import logging
+import os
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.instrumentation.flask import FlaskInstrumentor
+from opentelemetry.instrumentation.requests import RequestsInstrumentor
+
+logger = logging.getLogger(__name__)
+
+
+def init_tracer(service_name: str) -> trace.Tracer:
+ """
+ Initialize OpenTelemetry tracer with OTLP HTTP exporter for Jaeger.
+
+ Uses HTTP protocol like the Go API (instead of unreliable UDP).
+ Jaeger >= 1.35 supports OTLP natively on port 4318.
+
+ Args:
+ service_name: Name of the service for tracing
+
+ Returns:
+ Configured tracer instance
+ """
+ # Jaeger OTLP HTTP endpoint (port 4318)
+ otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://jaeger:4318")
+ sampling_rate = float(os.getenv("JAEGER_SAMPLING_RATE", "1.0"))
+
+ # Create resource with service name
+ resource = Resource.create({"service.name": service_name})
+
+ # Create tracer provider
+ provider = TracerProvider(resource=resource)
+
+ # Create OTLP HTTP exporter
+ otlp_exporter = OTLPSpanExporter(
+ endpoint=f"{otlp_endpoint}/v1/traces",
+ timeout=2, # 2 second timeout like Go API
+ )
+
+ # Add batch span processor (matches Go API's buffering)
+ processor = BatchSpanProcessor(
+ otlp_exporter,
+ max_queue_size=2048, # Default queue size
+ max_export_batch_size=512, # Must be <= max_queue_size
+ schedule_delay_millis=1000, # 1 second flush interval like Go API
+ )
+ provider.add_span_processor(processor)
+
+ # Set as global tracer provider
+ trace.set_tracer_provider(provider)
+
+ # Get tracer
+ tracer = trace.get_tracer(service_name)
+
+ # Auto-instrument Flask and requests
+ FlaskInstrumentor().instrument()
+ RequestsInstrumentor().instrument()
+
+ print(f"[TRACING] OpenTelemetry tracer initialized: service={service_name}, endpoint={otlp_endpoint}, sampling={sampling_rate}")
+
+ logger.info(
+ f"OpenTelemetry tracer initialized with OTLP HTTP exporter",
+ extra={
+ "service": service_name,
+ "otlp_endpoint": otlp_endpoint,
+ "sampling_rate": sampling_rate,
+ }
+ )
+
+ return tracer
diff --git a/src/utils/ui_helpers.py b/src/utils/ui_helpers.py
deleted file mode 100644
index d44561c6f145731104ef8c19c5f8d74de237f870..0000000000000000000000000000000000000000
--- a/src/utils/ui_helpers.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""
-UI helper utilities for Streamlit components.
-
-Provides reusable functions for name truncation, avatar generation, and time formatting.
-"""
-
-from datetime import datetime, timezone
-from typing import Optional
-
-
-def truncate_name(name: str, max_length: int = 25) -> tuple[str, bool]:
- """
- Truncate a session name to a maximum length with ellipsis.
-
- Args:
- name: Session name to truncate
- max_length: Maximum length before truncation (default: 25)
-
- Returns:
- tuple: (truncated_name, was_truncated)
-
- Example:
- >>> truncate_name("Very Long Session Name Here", 15)
- ("Very Long Se...", True)
- """
- if len(name) <= max_length:
- return name, False
-
- return name[:max_length] + "...", True
-
-
-def generate_avatar_svg(username: str, size: int = 40) -> str:
- """
- Generate an inline SVG avatar with user initials.
-
- Uses first 2 characters of username, uppercase.
- Background color derived from username hash for consistency.
-
- Args:
- username: Username to generate avatar for
- size: Size of SVG in pixels (default: 40)
-
- Returns:
- str: SVG markup as string
-
- Example:
- >>> svg = generate_avatar_svg("john_doe", 40)
- >>> assert '