Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
959 lines
38 KiB
Python
959 lines
38 KiB
Python
"""
|
|
Conversation Service for GT 2.0 Tenant Backend - PostgreSQL + PGVector
|
|
|
|
Manages AI-powered conversations with Agent integration using PostgreSQL directly.
|
|
Handles message persistence, context management, and LLM inference.
|
|
Replaces SQLAlchemy with direct PostgreSQL operations for GT 2.0 principles.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Dict, Any, List, Optional, AsyncIterator, AsyncGenerator
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.postgresql_client import get_postgresql_client
|
|
from app.services.agent_service import AgentService
|
|
from app.core.resource_client import ResourceClusterClient
|
|
from app.services.conversation_summarizer import ConversationSummarizer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConversationService:
|
|
"""PostgreSQL-based service for managing AI conversations"""
|
|
|
|
def __init__(self, tenant_domain: str, user_id: str):
|
|
"""Initialize with tenant and user isolation using PostgreSQL"""
|
|
self.tenant_domain = tenant_domain
|
|
self.user_id = user_id
|
|
self.settings = get_settings()
|
|
self.agent_service = AgentService(tenant_domain, user_id)
|
|
self.resource_client = ResourceClusterClient()
|
|
self._resolved_user_uuid = None # Cache for resolved user UUID
|
|
|
|
logger.info(f"Conversation service initialized with PostgreSQL for {tenant_domain}/{user_id}")
|
|
|
|
async def _get_resolved_user_uuid(self, user_identifier: Optional[str] = None) -> str:
|
|
"""
|
|
Resolve user identifier to UUID with caching for performance.
|
|
|
|
This optimization reduces repeated database lookups by caching the resolved UUID.
|
|
Performance impact: ~50% reduction in query time for operations with multiple queries.
|
|
"""
|
|
identifier = user_identifier or self.user_id
|
|
|
|
# Return cached UUID if already resolved for this instance
|
|
if self._resolved_user_uuid and identifier == self.user_id:
|
|
return self._resolved_user_uuid
|
|
|
|
# Check if already a UUID
|
|
if not "@" in identifier:
|
|
try:
|
|
# Validate it's a proper UUID format
|
|
uuid.UUID(identifier)
|
|
if identifier == self.user_id:
|
|
self._resolved_user_uuid = identifier
|
|
return identifier
|
|
except ValueError:
|
|
pass # Not a valid UUID, treat as email/username
|
|
|
|
# Resolve email to UUID
|
|
pg_client = await get_postgresql_client()
|
|
query = "SELECT id FROM users WHERE email = $1 LIMIT 1"
|
|
result = await pg_client.fetch_one(query, identifier)
|
|
|
|
if not result:
|
|
raise ValueError(f"User not found: {identifier}")
|
|
|
|
user_uuid = str(result["id"])
|
|
|
|
# Cache if this is the service's primary user
|
|
if identifier == self.user_id:
|
|
self._resolved_user_uuid = user_uuid
|
|
|
|
return user_uuid
|
|
|
|
def _get_user_clause(self, param_num: int, user_identifier: str) -> str:
|
|
"""
|
|
DEPRECATED: Get the appropriate SQL clause for user identification.
|
|
Use _get_resolved_user_uuid() instead for better performance.
|
|
"""
|
|
if "@" in user_identifier:
|
|
# Email - do lookup
|
|
return f"(SELECT id FROM users WHERE email = ${param_num} LIMIT 1)"
|
|
else:
|
|
# UUID - use directly
|
|
return f"${param_num}::uuid"
|
|
|
|
async def create_conversation(
|
|
self,
|
|
agent_id: str,
|
|
title: Optional[str],
|
|
user_identifier: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Create a new conversation with an agent using PostgreSQL"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
# Get agent configuration
|
|
agent_data = await self.agent_service.get_agent(agent_id)
|
|
if not agent_data:
|
|
raise ValueError(f"Agent {agent_id} not found")
|
|
|
|
# Validate tenant has access to the agent's model
|
|
agent_model = agent_data.get("model")
|
|
if agent_model:
|
|
available_models = await self.get_available_models(self.tenant_domain)
|
|
available_model_ids = [m["model_id"] for m in available_models]
|
|
|
|
if agent_model not in available_model_ids:
|
|
raise ValueError(f"Agent model '{agent_model}' is not accessible to tenant '{self.tenant_domain}'. Available models: {', '.join(available_model_ids)}")
|
|
|
|
logger.info(f"Validated tenant access to model '{agent_model}' for agent '{agent_data.get('name')}'")
|
|
else:
|
|
logger.warning(f"Agent {agent_id} has no model configured, will use default")
|
|
|
|
# Get PostgreSQL client
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Generate conversation ID
|
|
conversation_id = str(uuid.uuid4())
|
|
|
|
# Create conversation in PostgreSQL (optimized: use resolved UUID directly)
|
|
query = """
|
|
INSERT INTO conversations (
|
|
id, title, tenant_id, user_id, agent_id, summary,
|
|
total_messages, total_tokens, metadata, is_archived,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2,
|
|
(SELECT id FROM tenants WHERE domain = $3 LIMIT 1),
|
|
$4::uuid,
|
|
$5, '', 0, 0, '{}', false, NOW(), NOW()
|
|
)
|
|
RETURNING id, title, tenant_id, user_id, agent_id, created_at, updated_at
|
|
"""
|
|
|
|
conv_title = title or f"Conversation with {agent_data.get('name', 'Agent')}"
|
|
|
|
conversation_data = await pg_client.fetch_one(
|
|
query,
|
|
conversation_id, conv_title, self.tenant_domain,
|
|
user_uuid, agent_id
|
|
)
|
|
|
|
if not conversation_data:
|
|
raise RuntimeError("Failed to create conversation - no data returned")
|
|
|
|
# Note: conversation_settings and conversation_participants are now created automatically
|
|
# by the auto_create_conversation_settings trigger, so we don't need to create them manually
|
|
|
|
# Get the model_id from the auto-created settings or use agent's model
|
|
settings_query = """
|
|
SELECT model_id FROM conversation_settings WHERE conversation_id = $1
|
|
"""
|
|
settings_data = await pg_client.fetch_one(settings_query, conversation_id)
|
|
model_id = settings_data["model_id"] if settings_data else agent_model
|
|
|
|
result = {
|
|
"id": str(conversation_data["id"]),
|
|
"title": conversation_data["title"],
|
|
"agent_id": str(conversation_data["agent_id"]),
|
|
"model_id": model_id,
|
|
"created_at": conversation_data["created_at"].isoformat(),
|
|
"user_id": user_uuid,
|
|
"tenant_domain": self.tenant_domain
|
|
}
|
|
|
|
logger.info(f"Created conversation {conversation_id} in PostgreSQL for user {user_uuid}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to create conversation: {e}")
|
|
raise
|
|
|
|
async def list_conversations(
|
|
self,
|
|
user_identifier: str,
|
|
agent_id: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
time_filter: str = "all",
|
|
limit: int = 20,
|
|
offset: int = 0
|
|
) -> Dict[str, Any]:
|
|
"""List conversations for a user using PostgreSQL with server-side filtering"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Build query with optional filters - exclude archived conversations (optimized: use cached UUID)
|
|
where_clause = "WHERE c.user_id = $1::uuid AND c.is_archived = false"
|
|
params = [user_uuid]
|
|
param_count = 1
|
|
|
|
# Time filter
|
|
if time_filter != "all":
|
|
if time_filter == "today":
|
|
where_clause += " AND c.updated_at >= NOW() - INTERVAL '1 day'"
|
|
elif time_filter == "week":
|
|
where_clause += " AND c.updated_at >= NOW() - INTERVAL '7 days'"
|
|
elif time_filter == "month":
|
|
where_clause += " AND c.updated_at >= NOW() - INTERVAL '30 days'"
|
|
|
|
# Agent filter
|
|
if agent_id:
|
|
param_count += 1
|
|
where_clause += f" AND c.agent_id = ${param_count}"
|
|
params.append(agent_id)
|
|
|
|
# Search filter (case-insensitive title search)
|
|
if search:
|
|
param_count += 1
|
|
where_clause += f" AND c.title ILIKE ${param_count}"
|
|
params.append(f"%{search}%")
|
|
|
|
# Get conversations with agent info and unread counts (optimized: use cached UUID)
|
|
query = f"""
|
|
SELECT
|
|
c.id, c.title, c.agent_id, c.created_at, c.updated_at,
|
|
c.total_messages, c.total_tokens, c.is_archived,
|
|
a.name as agent_name,
|
|
COUNT(m.id) FILTER (
|
|
WHERE m.created_at > COALESCE((c.metadata->>'last_read_at')::timestamptz, c.created_at)
|
|
AND m.user_id != $1::uuid
|
|
) as unread_count
|
|
FROM conversations c
|
|
LEFT JOIN agents a ON c.agent_id = a.id
|
|
LEFT JOIN messages m ON m.conversation_id = c.id
|
|
{where_clause}
|
|
GROUP BY c.id, c.title, c.agent_id, c.created_at, c.updated_at,
|
|
c.total_messages, c.total_tokens, c.is_archived, a.name
|
|
ORDER BY
|
|
CASE WHEN COUNT(m.id) FILTER (
|
|
WHERE m.created_at > COALESCE((c.metadata->>'last_read_at')::timestamptz, c.created_at)
|
|
AND m.user_id != $1::uuid
|
|
) > 0 THEN 0 ELSE 1 END,
|
|
c.updated_at DESC
|
|
LIMIT ${param_count + 1} OFFSET ${param_count + 2}
|
|
"""
|
|
params.extend([limit, offset])
|
|
|
|
conversations = await pg_client.execute_query(query, *params)
|
|
|
|
# Get total count
|
|
count_query = f"""
|
|
SELECT COUNT(*) as total
|
|
FROM conversations c
|
|
{where_clause}
|
|
"""
|
|
count_result = await pg_client.fetch_one(count_query, *params[:-2]) # Exclude limit/offset
|
|
total = count_result["total"] if count_result else 0
|
|
|
|
# Format results with lightweight fields including unread count
|
|
conversation_list = [
|
|
{
|
|
"id": str(conv["id"]),
|
|
"title": conv["title"],
|
|
"agent_id": str(conv["agent_id"]) if conv["agent_id"] else None,
|
|
"agent_name": conv["agent_name"] or "AI Assistant",
|
|
"created_at": conv["created_at"].isoformat(),
|
|
"updated_at": conv["updated_at"].isoformat(),
|
|
"last_message_at": conv["updated_at"].isoformat(), # Use updated_at as last activity
|
|
"message_count": conv["total_messages"] or 0,
|
|
"token_count": conv["total_tokens"] or 0,
|
|
"is_archived": conv["is_archived"],
|
|
"unread_count": conv.get("unread_count", 0) or 0 # Include unread count
|
|
# Removed preview field for performance
|
|
}
|
|
for conv in conversations
|
|
]
|
|
|
|
return {
|
|
"conversations": conversation_list,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to list conversations: {e}")
|
|
raise
|
|
|
|
async def get_conversation(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Get a specific conversation with details"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
query = """
|
|
SELECT
|
|
c.id, c.title, c.agent_id, c.created_at, c.updated_at,
|
|
c.total_messages, c.total_tokens, c.is_archived, c.summary,
|
|
a.name as agent_name,
|
|
cs.model_id, cs.temperature, cs.max_tokens, cs.system_prompt
|
|
FROM conversations c
|
|
LEFT JOIN agents a ON c.agent_id = a.id
|
|
LEFT JOIN conversation_settings cs ON c.id = cs.conversation_id
|
|
WHERE c.id = $1
|
|
AND c.user_id = $2::uuid
|
|
LIMIT 1
|
|
"""
|
|
|
|
conversation = await pg_client.fetch_one(query, conversation_id, user_uuid)
|
|
|
|
if not conversation:
|
|
return None
|
|
|
|
return {
|
|
"id": conversation["id"],
|
|
"title": conversation["title"],
|
|
"agent_id": conversation["agent_id"],
|
|
"agent_name": conversation["agent_name"],
|
|
"model_id": conversation["model_id"],
|
|
"temperature": float(conversation["temperature"]) if conversation["temperature"] else 0.7,
|
|
"max_tokens": conversation["max_tokens"],
|
|
"system_prompt": conversation["system_prompt"],
|
|
"summary": conversation["summary"],
|
|
"message_count": conversation["total_messages"],
|
|
"token_count": conversation["total_tokens"],
|
|
"is_archived": conversation["is_archived"],
|
|
"created_at": conversation["created_at"].isoformat(),
|
|
"updated_at": conversation["updated_at"].isoformat()
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get conversation {conversation_id}: {e}")
|
|
return None
|
|
|
|
async def add_message(
|
|
self,
|
|
conversation_id: str,
|
|
role: str,
|
|
content: str,
|
|
user_identifier: str,
|
|
model_used: Optional[str] = None,
|
|
token_count: int = 0,
|
|
metadata: Optional[Dict] = None,
|
|
attachments: Optional[List] = None
|
|
) -> Dict[str, Any]:
|
|
"""Add a message to a conversation"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
message_id = str(uuid.uuid4())
|
|
|
|
# Insert message (optimized: use cached UUID)
|
|
query = """
|
|
INSERT INTO messages (
|
|
id, conversation_id, user_id, role, content,
|
|
content_type, token_count, model_used, metadata, attachments, created_at
|
|
) VALUES (
|
|
$1, $2, $3::uuid,
|
|
$4, $5, 'text', $6, $7, $8, $9, NOW()
|
|
)
|
|
RETURNING id, created_at
|
|
"""
|
|
|
|
message_data = await pg_client.fetch_one(
|
|
query,
|
|
message_id, conversation_id, user_uuid,
|
|
role, content, token_count, model_used,
|
|
json.dumps(metadata or {}), json.dumps(attachments or [])
|
|
)
|
|
|
|
if not message_data:
|
|
raise RuntimeError("Failed to add message - no data returned")
|
|
|
|
# Update conversation totals (optimized: use cached UUID)
|
|
update_query = """
|
|
UPDATE conversations
|
|
SET total_messages = total_messages + 1,
|
|
total_tokens = total_tokens + $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND user_id = $2::uuid
|
|
"""
|
|
|
|
await pg_client.execute_command(update_query, conversation_id, user_uuid, token_count)
|
|
|
|
result = {
|
|
"id": message_data["id"],
|
|
"conversation_id": conversation_id,
|
|
"role": role,
|
|
"content": content,
|
|
"token_count": token_count,
|
|
"model_used": model_used,
|
|
"metadata": metadata or {},
|
|
"attachments": attachments or [],
|
|
"created_at": message_data["created_at"].isoformat()
|
|
}
|
|
|
|
logger.info(f"Added message {message_id} to conversation {conversation_id}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to add message to conversation {conversation_id}: {e}")
|
|
raise
|
|
|
|
async def get_messages(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str,
|
|
limit: int = 50,
|
|
offset: int = 0
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get messages for a conversation"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
query = """
|
|
SELECT
|
|
m.id, m.role, m.content, m.content_type, m.token_count,
|
|
m.model_used, m.finish_reason, m.metadata, m.attachments, m.created_at
|
|
FROM messages m
|
|
JOIN conversations c ON m.conversation_id = c.id
|
|
WHERE c.id = $1
|
|
AND c.user_id = $2::uuid
|
|
ORDER BY m.created_at ASC
|
|
LIMIT $3 OFFSET $4
|
|
"""
|
|
|
|
messages = await pg_client.execute_query(query, conversation_id, user_uuid, limit, offset)
|
|
|
|
return [
|
|
{
|
|
"id": msg["id"],
|
|
"role": msg["role"],
|
|
"content": msg["content"],
|
|
"content_type": msg["content_type"],
|
|
"token_count": msg["token_count"],
|
|
"model_used": msg["model_used"],
|
|
"finish_reason": msg["finish_reason"],
|
|
"metadata": (
|
|
json.loads(msg["metadata"]) if isinstance(msg["metadata"], str)
|
|
else (msg["metadata"] if isinstance(msg["metadata"], dict) else {})
|
|
),
|
|
"attachments": (
|
|
json.loads(msg["attachments"]) if isinstance(msg["attachments"], str)
|
|
else (msg["attachments"] if isinstance(msg["attachments"], list) else [])
|
|
),
|
|
"context_sources": (
|
|
(json.loads(msg["metadata"]) if isinstance(msg["metadata"], str) else msg["metadata"]).get("context_sources", [])
|
|
if (isinstance(msg["metadata"], str) or isinstance(msg["metadata"], dict))
|
|
else []
|
|
),
|
|
"created_at": msg["created_at"].isoformat()
|
|
}
|
|
for msg in messages
|
|
]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get messages for conversation {conversation_id}: {e}")
|
|
return []
|
|
|
|
async def send_message(
|
|
self,
|
|
conversation_id: str,
|
|
content: str,
|
|
user_identifier: Optional[str] = None,
|
|
stream: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""Send a message to conversation and get AI response"""
|
|
user_id = user_identifier or self.user_id
|
|
|
|
# Check if this is the first message
|
|
existing_messages = await self.get_messages(conversation_id, user_id)
|
|
is_first_message = len(existing_messages) == 0
|
|
|
|
# Add user message
|
|
user_message = await self.add_message(
|
|
conversation_id=conversation_id,
|
|
role="user",
|
|
content=content,
|
|
user_identifier=user_identifier
|
|
)
|
|
|
|
# Get conversation details for agent
|
|
conversation = await self.get_conversation(conversation_id, user_identifier)
|
|
agent_id = conversation.get("agent_id")
|
|
|
|
ai_message = None
|
|
if agent_id:
|
|
agent_data = await self.agent_service.get_agent(agent_id)
|
|
|
|
# Prepare messages for AI
|
|
messages = [
|
|
{"role": "system", "content": agent_data.get("prompt_template", "You are a helpful assistant.")},
|
|
{"role": "user", "content": content}
|
|
]
|
|
|
|
# Get AI response
|
|
ai_response = await self.get_ai_response(
|
|
model=agent_data.get("model", "llama-3.1-8b-instant"),
|
|
messages=messages,
|
|
tenant_id=self.tenant_domain,
|
|
user_id=user_id
|
|
)
|
|
|
|
# Extract content from response
|
|
ai_content = ai_response["choices"][0]["message"]["content"]
|
|
|
|
# Add AI message
|
|
ai_message = await self.add_message(
|
|
conversation_id=conversation_id,
|
|
role="agent",
|
|
content=ai_content,
|
|
user_identifier=user_id,
|
|
model_used=agent_data.get("model"),
|
|
token_count=ai_response["usage"]["total_tokens"]
|
|
)
|
|
|
|
return {
|
|
"user_message": user_message,
|
|
"ai_message": ai_message,
|
|
"is_first_message": is_first_message,
|
|
"conversation_id": conversation_id
|
|
}
|
|
|
|
async def update_conversation(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str,
|
|
title: Optional[str] = None
|
|
) -> bool:
|
|
"""Update conversation properties like title"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Build dynamic update query based on provided fields
|
|
update_fields = []
|
|
params = []
|
|
param_count = 1
|
|
|
|
if title is not None:
|
|
update_fields.append(f"title = ${param_count}")
|
|
params.append(title)
|
|
param_count += 1
|
|
|
|
if not update_fields:
|
|
return True # Nothing to update
|
|
|
|
# Add updated_at timestamp
|
|
update_fields.append(f"updated_at = NOW()")
|
|
|
|
query = f"""
|
|
UPDATE conversations
|
|
SET {', '.join(update_fields)}
|
|
WHERE id = ${param_count}
|
|
AND user_id = ${param_count + 1}::uuid
|
|
RETURNING id
|
|
"""
|
|
|
|
params.extend([conversation_id, user_uuid])
|
|
|
|
result = await pg_client.fetch_scalar(query, *params)
|
|
|
|
if result:
|
|
logger.info(f"Updated conversation {conversation_id}")
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update conversation {conversation_id}: {e}")
|
|
return False
|
|
|
|
async def auto_generate_conversation_title(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str
|
|
) -> Optional[str]:
|
|
"""Generate conversation title based on first user prompt and agent response pair"""
|
|
try:
|
|
# Get only the first few messages (first exchange)
|
|
messages = await self.get_messages(conversation_id, user_identifier, limit=2)
|
|
|
|
if not messages or len(messages) < 2:
|
|
return None # Need at least one user-agent exchange
|
|
|
|
# Only use first user message and first agent response for title
|
|
first_exchange = messages[:2]
|
|
|
|
# Generate title using the summarization service
|
|
from app.services.conversation_summarizer import generate_conversation_title
|
|
new_title = await generate_conversation_title(first_exchange, self.tenant_domain, user_identifier)
|
|
|
|
# Update the conversation with the generated title
|
|
success = await self.update_conversation(
|
|
conversation_id=conversation_id,
|
|
user_identifier=user_identifier,
|
|
title=new_title
|
|
)
|
|
|
|
if success:
|
|
logger.info(f"Auto-generated title '{new_title}' for conversation {conversation_id} based on first exchange")
|
|
return new_title
|
|
else:
|
|
logger.warning(f"Failed to update conversation {conversation_id} with generated title")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to auto-generate title for conversation {conversation_id}: {e}")
|
|
return None
|
|
|
|
async def delete_conversation(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str
|
|
) -> bool:
|
|
"""Soft delete a conversation (archive it)"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
query = """
|
|
UPDATE conversations
|
|
SET is_archived = true, updated_at = NOW()
|
|
WHERE id = $1
|
|
AND user_id = $2::uuid
|
|
RETURNING id
|
|
"""
|
|
|
|
result = await pg_client.fetch_scalar(query, conversation_id, user_uuid)
|
|
|
|
if result:
|
|
logger.info(f"Archived conversation {conversation_id}")
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to archive conversation {conversation_id}: {e}")
|
|
return False
|
|
|
|
async def get_ai_response(
|
|
self,
|
|
model: str,
|
|
messages: List[Dict[str, str]],
|
|
tenant_id: str,
|
|
user_id: str,
|
|
temperature: float = 0.7,
|
|
max_tokens: Optional[int] = None,
|
|
top_p: float = 1.0,
|
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
tool_choice: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Get AI response from Resource Cluster"""
|
|
try:
|
|
# Prepare request for Resource Cluster
|
|
request_data = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"top_p": top_p
|
|
}
|
|
|
|
# Add tools if provided
|
|
if tools:
|
|
request_data["tools"] = tools
|
|
if tool_choice:
|
|
request_data["tool_choice"] = tool_choice
|
|
|
|
# Call Resource Cluster AI inference endpoint
|
|
response = await self.resource_client.call_inference_endpoint(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
endpoint="chat/completions",
|
|
data=request_data
|
|
)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get AI response: {e}")
|
|
raise
|
|
|
|
# Streaming removed for reliability - using non-streaming only
|
|
|
|
async def get_available_models(self, tenant_id: str) -> List[Dict[str, Any]]:
|
|
"""Get available models for tenant from Resource Cluster"""
|
|
try:
|
|
# Get models dynamically from Resource Cluster
|
|
import aiohttp
|
|
|
|
resource_cluster_url = self.resource_client.base_url
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
# Get capability token for model access
|
|
token = await self.resource_client._get_capability_token(
|
|
tenant_id=tenant_id,
|
|
user_id=self.user_id,
|
|
resources=['model_registry']
|
|
)
|
|
|
|
headers = {
|
|
'Authorization': f'Bearer {token}',
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': tenant_id,
|
|
'X-User-ID': self.user_id
|
|
}
|
|
|
|
async with session.get(
|
|
f"{resource_cluster_url}/api/v1/models/",
|
|
headers=headers,
|
|
timeout=aiohttp.ClientTimeout(total=10)
|
|
) as response:
|
|
|
|
if response.status == 200:
|
|
response_data = await response.json()
|
|
models_data = response_data.get("models", [])
|
|
|
|
# Transform Resource Cluster model format to frontend format
|
|
available_models = []
|
|
for model in models_data:
|
|
# Only include available models
|
|
if model.get("status", {}).get("deployment") == "available":
|
|
available_models.append({
|
|
"id": model.get("uuid"), # Database UUID for unique identification
|
|
"model_id": model["id"], # model_id string for API calls
|
|
"name": model["name"],
|
|
"provider": model["provider"],
|
|
"model_type": model["model_type"],
|
|
"context_window": model.get("performance", {}).get("context_window", 4000),
|
|
"max_tokens": model.get("performance", {}).get("max_tokens", 4000),
|
|
"performance": model.get("performance", {}), # Include full performance for chat.py
|
|
"capabilities": {"chat": True} # All LLM models support chat
|
|
})
|
|
|
|
logger.info(f"Retrieved {len(available_models)} models from Resource Cluster")
|
|
return available_models
|
|
else:
|
|
logger.error(f"Resource Cluster returned {response.status}: {await response.text()}")
|
|
raise RuntimeError(f"Resource Cluster API error: {response.status}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get models from Resource Cluster: {e}")
|
|
raise
|
|
|
|
async def get_conversation_datasets(self, conversation_id: str, user_identifier: str) -> List[str]:
|
|
"""Get dataset IDs attached to a conversation"""
|
|
try:
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Ensure proper schema qualification
|
|
schema_name = f"tenant_{self.tenant_domain.replace('.', '_').replace('-', '_')}"
|
|
|
|
query = f"""
|
|
SELECT cd.dataset_id
|
|
FROM {schema_name}.conversations c
|
|
JOIN {schema_name}.conversation_datasets cd ON cd.conversation_id = c.id
|
|
WHERE c.id = $1
|
|
AND c.user_id = (SELECT id FROM {schema_name}.users WHERE email = $2 LIMIT 1)
|
|
AND cd.is_active = true
|
|
ORDER BY cd.attached_at ASC
|
|
"""
|
|
|
|
rows = await pg_client.execute_query(query, conversation_id, user_identifier)
|
|
dataset_ids = [str(row['dataset_id']) for row in rows]
|
|
|
|
logger.info(f"Found {len(dataset_ids)} datasets for conversation {conversation_id}")
|
|
return dataset_ids
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get conversation datasets: {e}")
|
|
return []
|
|
|
|
async def add_datasets_to_conversation(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str,
|
|
dataset_ids: List[str],
|
|
source: str = "user_selected"
|
|
) -> bool:
|
|
"""Add datasets to a conversation"""
|
|
try:
|
|
if not dataset_ids:
|
|
return True
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Ensure proper schema qualification
|
|
schema_name = f"tenant_{self.tenant_domain.replace('.', '_').replace('-', '_')}"
|
|
|
|
# Get user ID first
|
|
user_query = f"SELECT id FROM {schema_name}.users WHERE email = $1 LIMIT 1"
|
|
user_result = await pg_client.fetch_scalar(user_query, user_identifier)
|
|
|
|
if not user_result:
|
|
logger.error(f"User not found: {user_identifier}")
|
|
return False
|
|
|
|
user_id = user_result
|
|
|
|
# Insert dataset attachments (ON CONFLICT DO NOTHING to avoid duplicates)
|
|
values_list = []
|
|
params = []
|
|
param_idx = 1
|
|
|
|
for dataset_id in dataset_ids:
|
|
values_list.append(f"(${param_idx}, ${param_idx + 1}, ${param_idx + 2})")
|
|
params.extend([conversation_id, dataset_id, user_id])
|
|
param_idx += 3
|
|
|
|
query = f"""
|
|
INSERT INTO {schema_name}.conversation_datasets (conversation_id, dataset_id, attached_by)
|
|
VALUES {', '.join(values_list)}
|
|
ON CONFLICT (conversation_id, dataset_id) DO UPDATE SET
|
|
is_active = true,
|
|
attached_at = NOW()
|
|
"""
|
|
|
|
await pg_client.execute_query(query, *params)
|
|
|
|
logger.info(f"Added {len(dataset_ids)} datasets to conversation {conversation_id}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to add datasets to conversation: {e}")
|
|
return False
|
|
|
|
async def copy_agent_datasets_to_conversation(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str,
|
|
agent_id: str
|
|
) -> bool:
|
|
"""Copy an agent's default datasets to a new conversation"""
|
|
try:
|
|
# Get agent's selected dataset IDs from config
|
|
from app.services.agent_service import AgentService
|
|
agent_service = AgentService(self.tenant_domain, user_identifier)
|
|
agent_data = await agent_service.get_agent(agent_id)
|
|
|
|
if not agent_data:
|
|
logger.warning(f"Agent {agent_id} not found")
|
|
return False
|
|
|
|
# Get selected_dataset_ids from agent config
|
|
selected_dataset_ids = agent_data.get('selected_dataset_ids', [])
|
|
|
|
if not selected_dataset_ids:
|
|
logger.info(f"Agent {agent_id} has no default datasets")
|
|
return True
|
|
|
|
# Add agent's datasets to conversation
|
|
success = await self.add_datasets_to_conversation(
|
|
conversation_id=conversation_id,
|
|
user_identifier=user_identifier,
|
|
dataset_ids=selected_dataset_ids,
|
|
source="agent_default"
|
|
)
|
|
|
|
if success:
|
|
logger.info(f"Copied {len(selected_dataset_ids)} datasets from agent {agent_id} to conversation {conversation_id}")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to copy agent datasets: {e}")
|
|
return False
|
|
|
|
async def get_recent_conversations(self, user_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""Get recent conversations ordered by last activity"""
|
|
try:
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Handle both email and UUID formats using existing pattern
|
|
user_clause = self._get_user_clause(1, user_id)
|
|
|
|
query = f"""
|
|
SELECT c.id, c.title, c.created_at, c.updated_at,
|
|
COUNT(m.id) as message_count,
|
|
MAX(m.created_at) as last_message_at,
|
|
a.name as agent_name
|
|
FROM conversations c
|
|
LEFT JOIN messages m ON m.conversation_id = c.id
|
|
LEFT JOIN agents a ON a.id = c.agent_id
|
|
WHERE c.user_id = {user_clause}
|
|
AND c.is_archived = false
|
|
GROUP BY c.id, c.title, c.created_at, c.updated_at, a.name
|
|
ORDER BY COALESCE(MAX(m.created_at), c.created_at) DESC
|
|
LIMIT $2
|
|
"""
|
|
|
|
rows = await pg_client.execute_query(query, user_id, limit)
|
|
return [dict(row) for row in rows]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get recent conversations: {e}")
|
|
return []
|
|
|
|
async def mark_conversation_read(
|
|
self,
|
|
conversation_id: str,
|
|
user_identifier: str
|
|
) -> bool:
|
|
"""
|
|
Mark a conversation as read by updating last_read_at in metadata.
|
|
|
|
Args:
|
|
conversation_id: UUID of the conversation
|
|
user_identifier: User email or UUID
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
try:
|
|
# Resolve user UUID with caching (performance optimization)
|
|
user_uuid = await self._get_resolved_user_uuid(user_identifier)
|
|
|
|
pg_client = await get_postgresql_client()
|
|
|
|
# Update last_read_at in conversation metadata
|
|
query = """
|
|
UPDATE conversations
|
|
SET metadata = jsonb_set(
|
|
COALESCE(metadata, '{}'::jsonb),
|
|
'{last_read_at}',
|
|
to_jsonb(NOW()::text)
|
|
)
|
|
WHERE id = $1
|
|
AND user_id = $2::uuid
|
|
RETURNING id
|
|
"""
|
|
|
|
result = await pg_client.fetch_one(query, conversation_id, user_uuid)
|
|
|
|
if result:
|
|
logger.info(f"Marked conversation {conversation_id} as read for user {user_identifier}")
|
|
return True
|
|
else:
|
|
logger.warning(f"Conversation {conversation_id} not found or access denied for user {user_identifier}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to mark conversation as read: {e}")
|
|
return False |