1985 lines
63 KiB
Python
1985 lines
63 KiB
Python
from collections.abc import Sequence
|
|
from logging import getLogger
|
|
from typing import Any
|
|
|
|
from dotenv import load_dotenv
|
|
from nanoid import generate as generate_nanoid
|
|
from sqlalchemy import Select, cast, func, insert, select, update
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.engine import Row
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.types import BigInteger
|
|
|
|
from src.config import settings
|
|
from src.embeddings import EmbeddingClient
|
|
|
|
from . import models, schemas
|
|
from .exceptions import (
|
|
DisabledException,
|
|
ResourceNotFoundException,
|
|
ValidationException,
|
|
)
|
|
from .utils.filter import apply_filter
|
|
|
|
load_dotenv(override=True)
|
|
|
|
|
|
embedding_client = EmbeddingClient(settings.LLM.OPENAI_API_KEY)
|
|
|
|
logger = getLogger(__name__)
|
|
|
|
USER_REPRESENTATION_METADATA_KEY = "user_representation"
|
|
|
|
########################################################
|
|
# workspace methods
|
|
########################################################
|
|
|
|
|
|
async def get_or_create_workspace(
|
|
db: AsyncSession, workspace: schemas.WorkspaceCreate
|
|
) -> models.Workspace:
|
|
"""
|
|
Get an existing workspace or create a new one if it doesn't exist.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace: Workspace creation schema
|
|
|
|
Returns:
|
|
The workspace if found or created
|
|
|
|
Raises:
|
|
ConflictException: If there's an integrity error when creating the workspace
|
|
"""
|
|
# Try to get the existing workspace
|
|
stmt = select(models.Workspace).where(models.Workspace.name == workspace.name)
|
|
result = await db.execute(stmt)
|
|
existing_workspace = result.scalar_one_or_none()
|
|
|
|
if existing_workspace is not None:
|
|
# Workspace already exists
|
|
logger.debug(f"Found existing workspace: {workspace.name}")
|
|
return existing_workspace
|
|
|
|
# Workspace doesn't exist, create a new one
|
|
honcho_workspace = models.Workspace(
|
|
name=workspace.name,
|
|
h_metadata=workspace.metadata,
|
|
configuration=workspace.configuration,
|
|
)
|
|
db.add(honcho_workspace)
|
|
await db.commit()
|
|
logger.info(f"Workspace created successfully: {workspace.name}")
|
|
return honcho_workspace
|
|
|
|
|
|
async def get_all_workspaces(
|
|
filters: dict[str, Any] | None = None,
|
|
) -> Select[tuple[models.Workspace]]:
|
|
"""
|
|
Get all workspaces.
|
|
|
|
Args:
|
|
db: Database session
|
|
filters: Filter the workspaces by a dictionary of metadata
|
|
"""
|
|
stmt = select(models.Workspace)
|
|
stmt = apply_filter(stmt, models.Workspace, filters)
|
|
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
|
|
return stmt
|
|
|
|
|
|
async def update_workspace(
|
|
db: AsyncSession, workspace_name: str, workspace: schemas.WorkspaceUpdate
|
|
) -> models.Workspace:
|
|
"""
|
|
Update a workspace.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
workspace: Workspace update schema
|
|
|
|
Returns:
|
|
The updated workspace
|
|
"""
|
|
honcho_workspace = await get_or_create_workspace(
|
|
db,
|
|
schemas.WorkspaceCreate(
|
|
name=workspace_name,
|
|
metadata=workspace.metadata or {}, # Provide empty dict if metadata is None
|
|
),
|
|
)
|
|
|
|
if workspace.metadata is not None:
|
|
honcho_workspace.h_metadata = workspace.metadata
|
|
|
|
if workspace.configuration is not None:
|
|
honcho_workspace.configuration = workspace.configuration
|
|
|
|
await db.commit()
|
|
logger.info(f"Workspace with id {honcho_workspace.id} updated successfully")
|
|
return honcho_workspace
|
|
|
|
|
|
########################################################
|
|
# peer methods
|
|
########################################################
|
|
|
|
|
|
async def get_or_create_peers(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
peers: list[schemas.PeerCreate],
|
|
) -> list[models.Peer]:
|
|
"""
|
|
Get an existing list of peers or create new peers if they don't exist.
|
|
Updates existing peers with metadata and configuration if provided.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
peers: List of peer creation schemas
|
|
|
|
Returns:
|
|
List of peers if found or created
|
|
"""
|
|
peer_names = [p.name for p in peers]
|
|
stmt = (
|
|
select(models.Peer)
|
|
.where(models.Peer.workspace_name == workspace_name)
|
|
.where(models.Peer.name.in_(peer_names))
|
|
)
|
|
result = await db.execute(stmt)
|
|
existing_peers = list(result.scalars().all())
|
|
|
|
# Create a mapping of peer names to peer schemas for easy lookup
|
|
peer_schema_map = {p.name: p for p in peers}
|
|
|
|
# Update existing peers with metadata and configuration if provided
|
|
for existing_peer in existing_peers:
|
|
peer_schema = peer_schema_map[existing_peer.name]
|
|
|
|
# Update with metadata and configuration if provided
|
|
if peer_schema.metadata is not None:
|
|
existing_peer.h_metadata = peer_schema.metadata
|
|
|
|
if peer_schema.configuration is not None:
|
|
existing_peer.configuration = peer_schema.configuration
|
|
|
|
# Find which peers need to be created
|
|
existing_names = {p.name for p in existing_peers}
|
|
peers_to_create = [p for p in peers if p.name not in existing_names]
|
|
|
|
# Create new peers
|
|
new_peers = [
|
|
models.Peer(
|
|
workspace_name=workspace_name,
|
|
name=p.name,
|
|
h_metadata=p.metadata or {},
|
|
configuration=p.configuration or {},
|
|
)
|
|
for p in peers_to_create
|
|
]
|
|
db.add_all(new_peers)
|
|
|
|
await db.commit()
|
|
|
|
# Return combined list of existing and new peers
|
|
return existing_peers + new_peers
|
|
|
|
|
|
async def get_peer(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
peer: schemas.PeerCreate,
|
|
) -> models.Peer:
|
|
"""
|
|
Get an existing peer.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
peer: Peer creation schema
|
|
|
|
Returns:
|
|
The peer if found or created
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the peer does not exist
|
|
"""
|
|
# Try to get the existing peer
|
|
stmt = (
|
|
select(models.Peer)
|
|
.where(models.Peer.workspace_name == workspace_name)
|
|
.where(models.Peer.name == peer.name)
|
|
)
|
|
result = await db.execute(stmt)
|
|
existing_peer = result.scalar_one_or_none()
|
|
|
|
if existing_peer is not None:
|
|
return existing_peer
|
|
|
|
raise ResourceNotFoundException(
|
|
f"Peer {peer.name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
|
|
async def get_peers(
|
|
workspace_name: str,
|
|
filters: dict[str, str] | None = None,
|
|
) -> Select[tuple[models.Peer]]:
|
|
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
|
|
|
|
stmt = apply_filter(stmt, models.Peer, filters)
|
|
|
|
stmt = stmt.order_by(models.Peer.created_at)
|
|
|
|
return stmt
|
|
|
|
|
|
async def update_peer(
|
|
db: AsyncSession, workspace_name: str, peer_name: str, peer: schemas.PeerUpdate
|
|
) -> models.Peer:
|
|
"""
|
|
Update a peer.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
peer_name: Name of the peer
|
|
peer: Peer update schema
|
|
|
|
Returns:
|
|
The updated peer
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the peer does not exist
|
|
ValidationException: If the update data is invalid
|
|
ConflictException: If the update violates a unique constraint
|
|
"""
|
|
honcho_peer = (
|
|
await get_or_create_peers(
|
|
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
|
|
)
|
|
)[0]
|
|
|
|
if peer.metadata is not None:
|
|
honcho_peer.h_metadata = peer.metadata
|
|
|
|
if peer.configuration is not None:
|
|
honcho_peer.configuration = peer.configuration
|
|
|
|
await db.commit()
|
|
logger.info(f"Peer {peer_name} updated successfully")
|
|
return honcho_peer
|
|
|
|
|
|
async def get_sessions_for_peer(
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
filters: dict[str, Any] | None = None,
|
|
) -> Select[tuple[models.Session]]:
|
|
"""
|
|
Get all sessions for a peer through the session_peers relationship.
|
|
|
|
Args:
|
|
workspace_name: Name of the workspace
|
|
peer_name: Name of the peer
|
|
filters: Filter sessions by metadata
|
|
|
|
Returns:
|
|
SQLAlchemy Select statement
|
|
"""
|
|
stmt = (
|
|
select(models.Session)
|
|
.join(
|
|
models.SessionPeer,
|
|
(models.Session.name == models.SessionPeer.session_name)
|
|
& (models.Session.workspace_name == models.SessionPeer.workspace_name),
|
|
)
|
|
.where(models.SessionPeer.peer_name == peer_name)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
)
|
|
|
|
stmt = apply_filter(stmt, models.Session, filters)
|
|
|
|
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
|
|
|
|
return stmt
|
|
|
|
|
|
########################################################
|
|
# session methods
|
|
########################################################
|
|
|
|
|
|
async def get_sessions(
|
|
workspace_name: str,
|
|
filters: dict[str, Any] | None = None,
|
|
) -> Select[tuple[models.Session]]:
|
|
"""
|
|
Get all sessions in a workspace.
|
|
"""
|
|
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
|
|
|
|
stmt = apply_filter(stmt, models.Session, filters)
|
|
|
|
stmt = stmt.order_by(models.Session.created_at)
|
|
|
|
return stmt
|
|
|
|
|
|
async def get_or_create_session(
|
|
db: AsyncSession,
|
|
session: schemas.SessionCreate,
|
|
workspace_name: str,
|
|
) -> models.Session:
|
|
"""
|
|
Get or create a session in a workspace with specified peers.
|
|
If the session already exists, the peers are added to the session.
|
|
|
|
Args:
|
|
db: Database session
|
|
session: Session creation schema
|
|
workspace_name: Name of the workspace
|
|
peer_names: List of peer names to add to the session
|
|
|
|
Returns:
|
|
The created session
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist and create is false
|
|
"""
|
|
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == session.name)
|
|
)
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
honcho_session = result.scalar_one_or_none()
|
|
|
|
# Check if session already exists
|
|
if honcho_session is None:
|
|
if (
|
|
session.peer_names
|
|
and len(session.peer_names) > settings.SESSION_PEERS_LIMIT
|
|
):
|
|
raise ValueError(
|
|
f"Cannot create session {session.name} with {len(session.peer_names)} peers. Maximum allowed is {settings.SESSION_PEERS_LIMIT} peers per session."
|
|
)
|
|
|
|
# Create honcho session
|
|
|
|
honcho_session = models.Session(
|
|
workspace_name=workspace_name,
|
|
name=session.name,
|
|
h_metadata=session.metadata or {},
|
|
configuration=session.configuration or {},
|
|
)
|
|
db.add(honcho_session)
|
|
# Flush to ensure session exists in DB before adding peers
|
|
await db.flush()
|
|
else:
|
|
# Update existing session with metadata and feature flags if provided
|
|
if session.metadata is not None:
|
|
honcho_session.h_metadata = session.metadata
|
|
if session.configuration is not None:
|
|
honcho_session.configuration = session.configuration
|
|
|
|
# Add all peers to session
|
|
if session.peer_names:
|
|
await get_or_create_peers(
|
|
db,
|
|
workspace_name=workspace_name,
|
|
peers=[
|
|
schemas.PeerCreate(name=peer_name) for peer_name in session.peer_names
|
|
],
|
|
)
|
|
await _get_or_add_peers_to_session(
|
|
db,
|
|
workspace_name=workspace_name,
|
|
session_name=session.name,
|
|
peer_names=session.peer_names,
|
|
)
|
|
|
|
await db.commit()
|
|
logger.info(
|
|
f"Session {session.name} updated successfully in workspace {workspace_name} with {len(session.peer_names or [])} peers"
|
|
)
|
|
return honcho_session
|
|
|
|
|
|
async def get_session(
|
|
db: AsyncSession,
|
|
session_name: str,
|
|
workspace_name: str,
|
|
) -> models.Session:
|
|
"""
|
|
Get a session in a workspace.
|
|
|
|
Args:
|
|
db: Database session
|
|
session_name: Name of the session
|
|
workspace_name: Name of the workspace
|
|
|
|
Returns:
|
|
The session
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist
|
|
"""
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == session_name)
|
|
)
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
honcho_session = result.scalar_one_or_none()
|
|
|
|
if honcho_session is None:
|
|
raise ResourceNotFoundException(
|
|
f"Session {session_name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
return honcho_session
|
|
|
|
|
|
async def update_session(
|
|
db: AsyncSession,
|
|
session: schemas.SessionUpdate,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
) -> models.Session:
|
|
"""
|
|
Update a session.
|
|
|
|
Args:
|
|
db: Database session
|
|
session: Session update schema
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
|
|
Returns:
|
|
The updated session
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist or peer is not in session
|
|
"""
|
|
honcho_session = await get_or_create_session(
|
|
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
|
|
)
|
|
|
|
if session.metadata is not None:
|
|
honcho_session.h_metadata = session.metadata
|
|
|
|
if session.configuration is not None:
|
|
honcho_session.configuration = session.configuration
|
|
|
|
await db.commit()
|
|
logger.info(f"Session {session_name} updated successfully")
|
|
return honcho_session
|
|
|
|
|
|
async def delete_session(
|
|
db: AsyncSession, workspace_name: str, session_name: str
|
|
) -> bool:
|
|
"""
|
|
Mark a session as inactive (soft delete).
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
|
|
Returns:
|
|
True if the session was deleted successfully
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist
|
|
"""
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == session_name)
|
|
)
|
|
result = await db.execute(stmt)
|
|
honcho_session = result.scalar_one_or_none()
|
|
|
|
if honcho_session is None:
|
|
logger.warning(
|
|
f"Session {session_name} not found in workspace {workspace_name}"
|
|
)
|
|
raise ResourceNotFoundException("Session not found")
|
|
|
|
honcho_session.is_active = False
|
|
await db.commit()
|
|
logger.info(f"Session {session_name} marked as inactive")
|
|
return True
|
|
|
|
|
|
async def clone_session(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
original_session_name: str,
|
|
cutoff_message_id: str | None = None,
|
|
) -> models.Session:
|
|
"""
|
|
Clone a session and its messages. If cutoff_message_id is provided,
|
|
only clone messages up to and including that message.
|
|
|
|
Args:
|
|
db: SQLAlchemy session
|
|
workspace_name: Name of the workspace the target session is in
|
|
original_session_name: Name of the session to clone
|
|
cutoff_message_id: Optional ID of the last message to include in the clone
|
|
|
|
Returns:
|
|
The newly created session
|
|
"""
|
|
# Get the original session
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == original_session_name)
|
|
)
|
|
result = await db.execute(stmt)
|
|
original_session = result.scalar_one_or_none()
|
|
if original_session is None:
|
|
raise ResourceNotFoundException("Original session not found")
|
|
|
|
# If cutoff_message_id is provided, verify it belongs to the session
|
|
cutoff_message = None
|
|
if cutoff_message_id is not None:
|
|
stmt = select(models.Message).where(
|
|
models.Message.public_id == cutoff_message_id,
|
|
models.Message.session_name == original_session_name,
|
|
)
|
|
cutoff_message = await db.scalar(stmt)
|
|
if not cutoff_message:
|
|
raise ValueError(
|
|
"Message not found or doesn't belong to the specified session"
|
|
)
|
|
|
|
# Create new session
|
|
new_session = models.Session(
|
|
workspace_name=workspace_name,
|
|
name=generate_nanoid(),
|
|
h_metadata=original_session.h_metadata,
|
|
)
|
|
db.add(new_session)
|
|
await db.flush() # Flush to get the new session ID
|
|
|
|
# Build query for messages to clone
|
|
stmt = select(models.Message).where(
|
|
models.Message.session_name == original_session_name
|
|
)
|
|
if cutoff_message_id is not None and cutoff_message is not None:
|
|
stmt = stmt.where(models.Message.id <= cast(cutoff_message.id, BigInteger))
|
|
stmt = stmt.order_by(models.Message.id)
|
|
|
|
# Fetch messages to clone
|
|
messages_to_clone_scalars = await db.scalars(stmt)
|
|
messages_to_clone = messages_to_clone_scalars.all()
|
|
|
|
if not messages_to_clone:
|
|
return new_session
|
|
|
|
# Prepare bulk insert data
|
|
new_messages = [
|
|
{
|
|
"session_name": new_session.name,
|
|
"content": message.content,
|
|
"h_metadata": message.h_metadata,
|
|
"workspace_name": workspace_name,
|
|
"peer_name": message.peer_name,
|
|
}
|
|
for message in messages_to_clone
|
|
]
|
|
|
|
insert_stmt = insert(models.Message).returning(models.Message)
|
|
result = await db.execute(insert_stmt, new_messages)
|
|
|
|
# Clone peers from original session to new session
|
|
stmt = select(models.SessionPeer).where(
|
|
models.SessionPeer.session_name == original_session_name
|
|
)
|
|
result = await db.execute(stmt)
|
|
session_peers = result.scalars().all()
|
|
for session_peer in session_peers:
|
|
new_session_peer = models.SessionPeer(
|
|
session_name=new_session.name,
|
|
peer_name=session_peer.peer_name,
|
|
workspace_name=workspace_name,
|
|
)
|
|
db.add(new_session_peer)
|
|
|
|
await db.commit()
|
|
logger.info(f"Session {original_session_name} cloned successfully")
|
|
return new_session
|
|
|
|
|
|
async def remove_peers_from_session(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
peer_names: set[str],
|
|
) -> bool:
|
|
"""
|
|
Remove specified peers from a session.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
peer_names: Set of peer names to remove from the session
|
|
|
|
Returns:
|
|
True if peers were removed successfully
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist
|
|
"""
|
|
# Verify session exists
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == session_name)
|
|
)
|
|
result = await db.execute(stmt)
|
|
session = result.scalar_one_or_none()
|
|
|
|
if session is None:
|
|
raise ResourceNotFoundException(
|
|
f"Session {session_name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
# Soft delete specified session peers by setting left_at timestamp
|
|
update_stmt = (
|
|
update(models.SessionPeer)
|
|
.where(
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.peer_name.in_(peer_names),
|
|
models.SessionPeer.left_at.is_(None), # Only update active peers
|
|
)
|
|
.values(left_at=func.now())
|
|
)
|
|
result = await db.execute(update_stmt)
|
|
|
|
await db.commit()
|
|
return True
|
|
|
|
|
|
async def get_peers_from_session(
|
|
workspace_name: str,
|
|
session_name: str,
|
|
) -> Select[tuple[models.Peer]]:
|
|
"""
|
|
Get all peers from a session.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
|
|
Returns:
|
|
Paginated list of Peer objects in the session
|
|
"""
|
|
# Get all active peers in the session (where left_at is NULL)
|
|
stmt = (
|
|
select(models.Peer)
|
|
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
|
|
.where(models.SessionPeer.session_name == session_name)
|
|
.where(models.Peer.workspace_name == workspace_name)
|
|
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
|
|
)
|
|
|
|
return stmt
|
|
|
|
|
|
async def get_session_peer_configuration(
|
|
workspace_name: str,
|
|
session_name: str,
|
|
) -> Select[tuple[str, dict[str, Any], dict[str, Any]]]:
|
|
"""
|
|
Get configuration from both SessionPeer and Peer tables for active peers in a session.
|
|
|
|
Args:
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
|
|
Returns:
|
|
Select statement returning peer_name, peer_configuration, and session_peer_configuration
|
|
"""
|
|
stmt: Select[tuple[str, dict[str, Any], dict[str, Any]]] = (
|
|
select(
|
|
models.Peer.name.label("peer_name"),
|
|
models.Peer.configuration.label("peer_configuration"),
|
|
models.SessionPeer.configuration.label("session_peer_configuration"),
|
|
)
|
|
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
|
|
.where(models.SessionPeer.session_name == session_name)
|
|
.where(models.Peer.workspace_name == workspace_name)
|
|
.where(models.SessionPeer.workspace_name == workspace_name)
|
|
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
|
|
)
|
|
|
|
return stmt
|
|
|
|
|
|
async def set_peers_for_session(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
peer_names: dict[str, schemas.SessionPeerConfig],
|
|
) -> list[models.SessionPeer]:
|
|
"""
|
|
Set peers for a session, overwriting any existing peers.
|
|
If peers don't exist, they will be created.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
peer_names: Set of peer names to set for the session
|
|
|
|
Returns:
|
|
List of SessionPeer objects for all peers in the session
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist
|
|
"""
|
|
# Validate peer limit before making any changes
|
|
if len(peer_names) > settings.SESSION_PEERS_LIMIT:
|
|
raise ValueError(
|
|
f"Cannot set {len(peer_names)} peers for session {session_name}. Maximum allowed is {settings.SESSION_PEERS_LIMIT} peers per session."
|
|
)
|
|
|
|
# Verify session exists
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == session_name)
|
|
)
|
|
result = await db.execute(stmt)
|
|
session = result.scalar_one_or_none()
|
|
|
|
if session is None:
|
|
raise ResourceNotFoundException(
|
|
f"Session {session_name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
# Soft delete specified session peers by setting left_at timestamp
|
|
update_stmt = (
|
|
update(models.SessionPeer)
|
|
.where(
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.left_at.is_(None), # Only update active peers
|
|
)
|
|
.values(left_at=func.now())
|
|
)
|
|
result = await db.execute(update_stmt)
|
|
|
|
# Get or create peers
|
|
await get_or_create_peers(
|
|
db,
|
|
workspace_name=workspace_name,
|
|
peers=[schemas.PeerCreate(name=peer_name) for peer_name in peer_names],
|
|
)
|
|
|
|
# Add new peers to session
|
|
peers = await _get_or_add_peers_to_session(
|
|
db,
|
|
workspace_name=workspace_name,
|
|
session_name=session_name,
|
|
peer_names=peer_names,
|
|
)
|
|
|
|
await db.commit()
|
|
return peers
|
|
|
|
|
|
async def _get_or_add_peers_to_session(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
peer_names: dict[str, schemas.SessionPeerConfig],
|
|
) -> list[models.SessionPeer]:
|
|
"""
|
|
Add multiple peers to an existing session. If a peer already exists in the session,
|
|
it will be skipped gracefully.
|
|
|
|
Args:
|
|
db: Database session
|
|
session_name: Name of the session
|
|
peer_names: Set of peer names to add to the session
|
|
|
|
Returns:
|
|
List of all SessionPeer objects (both existing and newly created)
|
|
|
|
Raises:
|
|
ValueError: If adding peers would exceed the maximum limit
|
|
"""
|
|
# If no peers to add, skip the insert and just return existing active session peers
|
|
if not peer_names:
|
|
select_stmt = select(models.SessionPeer).where(
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.left_at.is_(None), # Only active peers
|
|
)
|
|
result = await db.execute(select_stmt)
|
|
return list(result.scalars().all())
|
|
|
|
# Check current number of active peers and validate limit before upsert
|
|
current_peers_stmt = select(models.SessionPeer.peer_name).where(
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.left_at.is_(None), # Only active peers
|
|
)
|
|
result = await db.execute(current_peers_stmt)
|
|
existing_peer_names = result.scalars().all()
|
|
|
|
new_peers = [name for name in peer_names if name not in existing_peer_names]
|
|
if len(new_peers) + len(existing_peer_names) > settings.SESSION_PEERS_LIMIT:
|
|
raise ValueError(
|
|
f"Cannot add {len(new_peers)} peer(s). Session already has {len(existing_peer_names)} peer(s) with {settings.SESSION_PEERS_LIMIT} peers per session."
|
|
)
|
|
|
|
# Use upsert to handle both new peers and rejoining peers
|
|
stmt = pg_insert(models.SessionPeer).values(
|
|
[
|
|
{
|
|
"session_name": session_name,
|
|
"peer_name": peer_name,
|
|
"workspace_name": workspace_name,
|
|
"joined_at": func.now(),
|
|
"left_at": None,
|
|
"configuration": configuration.model_dump(),
|
|
}
|
|
for peer_name, configuration in peer_names.items()
|
|
]
|
|
)
|
|
|
|
# On conflict, update joined_at and clear left_at (rejoin scenario)
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=["session_name", "peer_name", "workspace_name"],
|
|
set_={
|
|
"joined_at": func.now(),
|
|
"left_at": None,
|
|
},
|
|
)
|
|
await db.execute(stmt)
|
|
|
|
# Return all active session peers after the upsert
|
|
select_stmt = select(models.SessionPeer).where(
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.left_at.is_(None), # Only active peers
|
|
)
|
|
result = await db.execute(select_stmt)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_peer_config(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
peer_id: str,
|
|
) -> schemas.SessionPeerConfig:
|
|
"""
|
|
Get the configuration for a peer in a session.
|
|
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
peer_id: Name of the peer
|
|
|
|
|
|
Returns:
|
|
Configuration for the peer
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session or peer does not exist
|
|
"""
|
|
# Get row from session_peer table
|
|
stmt = select(models.SessionPeer).where(
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.peer_name == peer_id,
|
|
)
|
|
result = await db.execute(stmt)
|
|
session_peer = result.scalar_one_or_none()
|
|
|
|
if session_peer is None:
|
|
raise ResourceNotFoundException(
|
|
f"Session peer {peer_id} not found in session {session_name} in workspace {workspace_name}"
|
|
)
|
|
|
|
return schemas.SessionPeerConfig(**session_peer.configuration)
|
|
|
|
|
|
async def set_peer_config(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
peer_id: str,
|
|
config: schemas.SessionPeerConfig,
|
|
) -> None:
|
|
"""
|
|
Set the configuration for a peer in a session.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
peer_id: Name of the peer
|
|
config: Configuration for the peer
|
|
|
|
Returns:
|
|
True if the peer config was set successfully
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session or peer does not exist
|
|
"""
|
|
# Get row from session_peer table
|
|
stmt = select(models.SessionPeer).where(
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.peer_name == peer_id,
|
|
)
|
|
result = await db.execute(stmt)
|
|
session_peer = result.scalar_one_or_none()
|
|
|
|
if session_peer is None:
|
|
raise ResourceNotFoundException(
|
|
f"Session peer {peer_id} not found in session {session_name} in workspace {workspace_name}"
|
|
)
|
|
|
|
# Update peer config
|
|
session_peer.configuration["observe_others"] = config.observe_others
|
|
session_peer.configuration["observe_me"] = config.observe_me
|
|
|
|
await db.commit()
|
|
|
|
|
|
async def search(
|
|
query: str,
|
|
*,
|
|
workspace_name: str,
|
|
session_name: str | None = None,
|
|
peer_name: str | None = None,
|
|
semantic: bool | None = None,
|
|
) -> Select[tuple[models.Message]]:
|
|
"""
|
|
Search across message content using a hybrid approach:
|
|
- Uses semantic search if embed_messages is set, else fall back to full text
|
|
- Uses PostgreSQL full text search for natural language queries
|
|
- Falls back to exact string matching for queries with special characters
|
|
- Optionally uses semantic search with embeddings
|
|
|
|
If a session or peer is provided, the search will be scoped to that
|
|
session or peer. Otherwise, it will search across all messages in the workspace.
|
|
|
|
Args:
|
|
query: Search query to match against message content
|
|
workspace_name: Name of the workspace
|
|
session_name: Optional name of the session
|
|
peer_name: Optional name of the peer
|
|
semantic: Optional boolean to configure semantic search:
|
|
- None: try semantic search if embed_messages is set, else fall back to full text
|
|
- True: try semantic search if embed_messages is set, else throw error
|
|
- False: use full text search
|
|
|
|
Returns:
|
|
List of messages that match the search query, ordered by relevance
|
|
"""
|
|
import re
|
|
|
|
from sqlalchemy import func, or_
|
|
|
|
# Base query conditions
|
|
base_conditions = [models.Message.workspace_name == workspace_name]
|
|
|
|
should_use_semantic_search = False # Default to full text search
|
|
|
|
if semantic is None:
|
|
# Try semantic search if embed_messages is set, else fall back to full text
|
|
should_use_semantic_search = settings.LLM.EMBED_MESSAGES
|
|
elif semantic is True:
|
|
# Try semantic search if embed_messages is set, else throw error
|
|
if settings.LLM.EMBED_MESSAGES:
|
|
should_use_semantic_search = True
|
|
else:
|
|
raise DisabledException(
|
|
"Semantic search requires EMBED_MESSAGES flag to be enabled"
|
|
)
|
|
|
|
if should_use_semantic_search:
|
|
# Generate embedding for the search query
|
|
try:
|
|
embedding_query = await embedding_client.embed(query)
|
|
except ValueError as e:
|
|
raise ValidationException(
|
|
f"Query exceeds maximum token limit of {settings.LLM.MAX_EMBEDDING_TOKENS}."
|
|
) from e
|
|
|
|
# Use cosine distance for semantic search on MessageEmbedding table
|
|
# Join with Message table to get the actual message data
|
|
base_query = (
|
|
select(models.Message)
|
|
.join(
|
|
models.MessageEmbedding,
|
|
models.Message.public_id == models.MessageEmbedding.message_id,
|
|
)
|
|
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
|
.order_by(
|
|
models.MessageEmbedding.embedding.cosine_distance(embedding_query)
|
|
)
|
|
)
|
|
|
|
if session_name is not None:
|
|
stmt = base_query.where(
|
|
models.MessageEmbedding.session_name == session_name
|
|
)
|
|
elif peer_name is not None:
|
|
stmt = base_query.where(models.MessageEmbedding.peer_name == peer_name)
|
|
else:
|
|
stmt = base_query
|
|
|
|
else:
|
|
# Check if query contains special characters that FTS might not handle well
|
|
has_special_chars = bool(
|
|
re.search(r'[~`!@#$%^&*()_+=\[\]{};\':"\\|,.<>/?-]', query)
|
|
)
|
|
|
|
if has_special_chars:
|
|
# For queries with special characters, use exact string matching (ILIKE)
|
|
# This ensures we can find exact matches like "~special-uuid~"
|
|
search_condition = models.Message.content.ilike(f"%{query}%")
|
|
|
|
base_query = (
|
|
select(models.Message)
|
|
.where(*base_conditions, search_condition)
|
|
.order_by(models.Message.created_at.desc())
|
|
)
|
|
else:
|
|
# For natural language queries, use full text search with ranking
|
|
fts_condition = func.to_tsvector("english", models.Message.content).op(
|
|
"@@"
|
|
)(func.plainto_tsquery("english", query))
|
|
|
|
# Combine FTS with ILIKE as fallback for better coverage
|
|
combined_condition = or_(
|
|
fts_condition, models.Message.content.ilike(f"%{query}%")
|
|
)
|
|
|
|
base_query = (
|
|
select(models.Message)
|
|
.where(*base_conditions, combined_condition)
|
|
.order_by(
|
|
# Order by FTS relevance first, then by creation time
|
|
func.coalesce(
|
|
func.ts_rank(
|
|
func.to_tsvector("english", models.Message.content),
|
|
func.plainto_tsquery("english", query),
|
|
),
|
|
0,
|
|
).desc(),
|
|
models.Message.created_at.desc(),
|
|
)
|
|
)
|
|
|
|
# Add additional filters based on parameters
|
|
if session_name is not None:
|
|
stmt = base_query.where(models.Message.session_name == session_name)
|
|
elif peer_name is not None:
|
|
stmt = base_query.where(models.Message.peer_name == peer_name)
|
|
else:
|
|
stmt = base_query
|
|
|
|
return stmt
|
|
|
|
|
|
async def get_working_representation(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
session_name: str | None = None,
|
|
) -> str:
|
|
if session_name:
|
|
# Fetch the latest user representation from the same session
|
|
logger.debug(f"Fetching latest representation for session {session_name}")
|
|
latest_representation_stmt = (
|
|
select(models.SessionPeer)
|
|
.where(models.SessionPeer.workspace_name == workspace_name)
|
|
.where(models.SessionPeer.peer_name == peer_name)
|
|
.where(models.SessionPeer.session_name == session_name)
|
|
.limit(1)
|
|
)
|
|
result = await db.execute(latest_representation_stmt)
|
|
latest_representation_obj = result.scalar_one_or_none()
|
|
latest_representation = (
|
|
latest_representation_obj.internal_metadata.get(
|
|
USER_REPRESENTATION_METADATA_KEY, ""
|
|
)
|
|
if latest_representation_obj
|
|
else ""
|
|
)
|
|
else:
|
|
# Fetch the latest global level user representation
|
|
logger.debug("Fetching latest global level user representation")
|
|
latest_representation_stmt = (
|
|
select(models.Peer)
|
|
.where(models.Peer.workspace_name == workspace_name)
|
|
.where(models.Peer.name == peer_name)
|
|
)
|
|
result = await db.execute(latest_representation_stmt)
|
|
latest_representation_obj = result.scalar_one_or_none()
|
|
latest_representation = (
|
|
latest_representation_obj.internal_metadata.get(
|
|
USER_REPRESENTATION_METADATA_KEY, ""
|
|
)
|
|
if latest_representation_obj
|
|
else ""
|
|
)
|
|
|
|
return latest_representation
|
|
|
|
|
|
async def set_working_representation(
|
|
db: AsyncSession,
|
|
representation: str,
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
session_name: str | None = None,
|
|
) -> None:
|
|
if session_name:
|
|
# Get session peer and update its metadata with the representation
|
|
stmt = (
|
|
update(models.SessionPeer)
|
|
.where(models.SessionPeer.workspace_name == workspace_name)
|
|
.where(models.SessionPeer.peer_name == peer_name)
|
|
.where(models.SessionPeer.session_name == session_name)
|
|
.values(
|
|
internal_metadata=models.SessionPeer.internal_metadata.op("||")(
|
|
{USER_REPRESENTATION_METADATA_KEY: representation}
|
|
)
|
|
)
|
|
)
|
|
else:
|
|
# Get peer and update its metadata with the representation
|
|
stmt = (
|
|
update(models.Peer)
|
|
.where(models.Peer.workspace_name == workspace_name)
|
|
.where(models.Peer.name == peer_name)
|
|
.values(
|
|
internal_metadata=models.Peer.internal_metadata.op("||")(
|
|
{USER_REPRESENTATION_METADATA_KEY: representation}
|
|
)
|
|
)
|
|
)
|
|
await db.execute(stmt)
|
|
await db.commit()
|
|
|
|
|
|
########################################################
|
|
# Message Methods
|
|
########################################################
|
|
|
|
|
|
async def create_messages(
|
|
db: AsyncSession,
|
|
messages: list[schemas.MessageCreate],
|
|
workspace_name: str,
|
|
session_name: str,
|
|
) -> list[models.Message]:
|
|
"""
|
|
Bulk create messages for a session while maintaining order.
|
|
|
|
Args:
|
|
db: Database session
|
|
messages: List of messages to create
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session to create messages in
|
|
|
|
Returns:
|
|
List of created message objects
|
|
"""
|
|
# Get or create session with peers in messages list
|
|
peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages}
|
|
await get_or_create_session(
|
|
db,
|
|
session=schemas.SessionCreate(
|
|
name=session_name,
|
|
peers=peers,
|
|
),
|
|
workspace_name=workspace_name,
|
|
)
|
|
|
|
# Create list of message objects (this will trigger the before_insert event)
|
|
message_objects: list[models.Message] = []
|
|
for message in messages:
|
|
message_obj = models.Message(
|
|
session_name=session_name,
|
|
peer_name=message.peer_name,
|
|
content=message.content,
|
|
h_metadata=message.metadata or {},
|
|
workspace_name=workspace_name,
|
|
public_id=generate_nanoid(),
|
|
token_count=len(message.encoded_message),
|
|
)
|
|
message_objects.append(message_obj)
|
|
|
|
db.add_all(message_objects)
|
|
await db.flush()
|
|
|
|
if settings.LLM.EMBED_MESSAGES:
|
|
encoded_message_lookup = {
|
|
msg.public_id: orig_msg.encoded_message
|
|
for msg, orig_msg in zip(message_objects, messages, strict=True)
|
|
}
|
|
id_resource_dict = {
|
|
message.public_id: (
|
|
message.content,
|
|
encoded_message_lookup[message.public_id],
|
|
)
|
|
for message in message_objects
|
|
}
|
|
embedding_dict = await embedding_client.batch_embed(id_resource_dict)
|
|
|
|
# Create MessageEmbedding entries for each embedded message
|
|
embedding_objects: list[models.MessageEmbedding] = []
|
|
for message_obj in message_objects:
|
|
embeddings = embedding_dict.get(message_obj.public_id, [])
|
|
for embedding in embeddings:
|
|
embedding_obj = models.MessageEmbedding(
|
|
content=message_obj.content,
|
|
embedding=embedding,
|
|
message_id=message_obj.public_id,
|
|
workspace_name=workspace_name,
|
|
session_name=session_name,
|
|
peer_name=message_obj.peer_name,
|
|
)
|
|
embedding_objects.append(embedding_obj)
|
|
|
|
# Add all embedding objects to the session
|
|
if embedding_objects:
|
|
db.add_all(embedding_objects)
|
|
|
|
await db.commit()
|
|
|
|
return message_objects
|
|
|
|
|
|
async def create_messages_for_peer(
|
|
db: AsyncSession,
|
|
messages: list[schemas.MessageCreate],
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
) -> list[models.Message]:
|
|
"""
|
|
Bulk create messages for a peer while maintaining order.
|
|
Note that session_name for messages created this way will be None
|
|
and peer_name will be the provided peer_name for each message,
|
|
regardless of the peer_name in the individual message(s).
|
|
|
|
Args:
|
|
db: Database session
|
|
messages: List of messages to create
|
|
workspace_name: Name of the workspace
|
|
peer_name: Name of the peer to create messages for
|
|
|
|
Returns:
|
|
List of created message objects
|
|
"""
|
|
await get_or_create_peers(
|
|
db, workspace_name=workspace_name, peers=[schemas.PeerCreate(name=peer_name)]
|
|
)
|
|
# Create list of message objects (this will trigger the before_insert event)
|
|
message_objects: list[models.Message] = []
|
|
|
|
for message in messages:
|
|
message_obj = models.Message(
|
|
session_name=None,
|
|
peer_name=peer_name,
|
|
content=message.content,
|
|
h_metadata=message.metadata or {},
|
|
workspace_name=workspace_name,
|
|
public_id=generate_nanoid(),
|
|
token_count=len(message.encoded_message),
|
|
)
|
|
message_objects.append(message_obj)
|
|
|
|
db.add_all(message_objects)
|
|
await db.flush()
|
|
|
|
if settings.LLM.EMBED_MESSAGES:
|
|
encoded_message_lookup = {
|
|
msg.public_id: orig_msg.encoded_message
|
|
for msg, orig_msg in zip(message_objects, messages, strict=True)
|
|
}
|
|
id_resource_dict = {
|
|
message.public_id: (
|
|
message.content,
|
|
encoded_message_lookup[message.public_id],
|
|
)
|
|
for message in message_objects
|
|
}
|
|
embedding_dict = await embedding_client.batch_embed(id_resource_dict)
|
|
|
|
# Create MessageEmbedding entries for each embedded message
|
|
embedding_objects: list[models.MessageEmbedding] = []
|
|
for message_obj in message_objects:
|
|
embeddings = embedding_dict.get(message_obj.public_id, [])
|
|
for embedding in embeddings:
|
|
embedding_obj = models.MessageEmbedding(
|
|
content=message_obj.content,
|
|
embedding=embedding,
|
|
message_id=message_obj.public_id,
|
|
workspace_name=workspace_name,
|
|
peer_name=peer_name,
|
|
)
|
|
embedding_objects.append(embedding_obj)
|
|
|
|
# Add all embedding objects to the session
|
|
if embedding_objects:
|
|
db.add_all(embedding_objects)
|
|
|
|
await db.commit()
|
|
|
|
return message_objects
|
|
|
|
|
|
async def get_messages(
|
|
workspace_name: str,
|
|
session_name: str,
|
|
reverse: bool | None = False,
|
|
filters: dict[str, Any] | None = None,
|
|
token_limit: int | None = None,
|
|
message_count_limit: int | None = None,
|
|
) -> Select[tuple[models.Message]]:
|
|
"""
|
|
Get messages from a session. If token_limit is provided, the n most recent messages
|
|
with token count adding up to the limit will be returned. If message_count_limit is provided,
|
|
the n most recent messages will be returned. If both are provided, message_count_limit will be
|
|
used.
|
|
|
|
Args:
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
reverse: Whether to reverse the order of messages
|
|
filters: Filter to apply to the messages
|
|
token_limit: Maximum number of tokens to include in the messages
|
|
message_count_limit: Maximum number of messages to include
|
|
|
|
Returns:
|
|
Select statement for the messages
|
|
"""
|
|
# Base query with workspace and session filters
|
|
base_conditions = [
|
|
models.Message.workspace_name == workspace_name,
|
|
models.Message.session_name == session_name,
|
|
]
|
|
|
|
# Apply message count limit first (takes precedence over token limit)
|
|
if message_count_limit is not None:
|
|
stmt = select(models.Message).where(*base_conditions)
|
|
stmt = apply_filter(stmt, models.Message, filters)
|
|
# For message count limit, we want the most recent N messages
|
|
# So we order by id desc to get most recent, then apply limit
|
|
stmt = stmt.order_by(models.Message.id.desc()).limit(message_count_limit)
|
|
|
|
# Apply final ordering based on reverse parameter
|
|
if reverse:
|
|
stmt = stmt.order_by(models.Message.id.desc())
|
|
else:
|
|
stmt = stmt.order_by(models.Message.id.asc())
|
|
elif token_limit is not None:
|
|
# Apply token limit logic
|
|
# Create a subquery that calculates running sum of tokens for most recent messages
|
|
token_subquery = (
|
|
select(
|
|
models.Message.id,
|
|
func.sum(models.Message.token_count)
|
|
.over(order_by=models.Message.id.desc())
|
|
.label("running_token_sum"),
|
|
)
|
|
.where(*base_conditions)
|
|
.subquery()
|
|
)
|
|
|
|
# Select Message objects where running sum doesn't exceed token_limit
|
|
stmt = (
|
|
select(models.Message)
|
|
.join(token_subquery, models.Message.id == token_subquery.c.id)
|
|
.where(token_subquery.c.running_token_sum <= token_limit)
|
|
)
|
|
stmt = apply_filter(stmt, models.Message, filters)
|
|
|
|
# Apply final ordering based on reverse parameter
|
|
if reverse:
|
|
stmt = stmt.order_by(models.Message.id.desc())
|
|
else:
|
|
stmt = stmt.order_by(models.Message.id.asc())
|
|
else:
|
|
# Default case - no limits applied
|
|
stmt = select(models.Message).where(*base_conditions)
|
|
stmt = apply_filter(stmt, models.Message, filters)
|
|
if reverse:
|
|
stmt = stmt.order_by(models.Message.id.desc())
|
|
else:
|
|
stmt = stmt.order_by(models.Message.id.asc())
|
|
|
|
return stmt
|
|
|
|
|
|
async def get_messages_id_range(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str | None,
|
|
peer_name: str | None,
|
|
start_id: int = 0,
|
|
end_id: int | None = None,
|
|
) -> list[models.Message]:
|
|
"""
|
|
Get messages from a session or peer by primary key ID range.
|
|
If end_id is not provided, all messages after and including start_id will be returned.
|
|
If start_id is not provided, start will be beginning of session.
|
|
|
|
Note: list is exclusive of the end_id message.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
session_name: Name of the session
|
|
peer_name: Name of the peer
|
|
start_id: Primary key ID of the first message to return
|
|
end_id: Primary key ID of the last message (exclusive)
|
|
|
|
Returns:
|
|
List of messages
|
|
|
|
Raises:
|
|
ValueError: If both session_name and peer_name are not provided
|
|
"""
|
|
if start_id < 0 or (end_id is not None and (start_id >= end_id or end_id <= 1)):
|
|
return []
|
|
stmt = select(models.Message).where(
|
|
models.Message.workspace_name == workspace_name,
|
|
)
|
|
if end_id:
|
|
stmt = stmt.where(models.Message.id.between(start_id, end_id - 1))
|
|
else:
|
|
stmt = stmt.where(models.Message.id >= start_id)
|
|
|
|
if session_name:
|
|
stmt = stmt.where(models.Message.session_name == session_name)
|
|
elif peer_name:
|
|
stmt = stmt.where(models.Message.peer_name == peer_name).where(
|
|
models.Message.session_name.is_(None)
|
|
)
|
|
else:
|
|
raise ValueError("Either session_name or peer_name must be provided")
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_messages_for_peer(
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
reverse: bool | None = False,
|
|
filters: dict[str, Any] | None = None,
|
|
) -> Select[tuple[models.Message]]:
|
|
stmt = (
|
|
select(models.Message)
|
|
.where(models.Message.workspace_name == workspace_name)
|
|
.where(models.Message.peer_name == peer_name)
|
|
.where(models.Message.session_name.is_(None))
|
|
)
|
|
|
|
stmt = apply_filter(stmt, models.Message, filters)
|
|
|
|
if reverse:
|
|
stmt = stmt.order_by(models.Message.id.desc())
|
|
else:
|
|
stmt = stmt.order_by(models.Message.id)
|
|
|
|
return stmt
|
|
|
|
|
|
async def get_message(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
message_id: str,
|
|
) -> models.Message | None:
|
|
stmt = (
|
|
select(models.Message)
|
|
.where(models.Message.workspace_name == workspace_name)
|
|
.where(models.Message.session_name == session_name)
|
|
.where(models.Message.public_id == message_id)
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def update_message(
|
|
db: AsyncSession,
|
|
message: schemas.MessageUpdate,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
message_id: str,
|
|
) -> bool:
|
|
honcho_message = await get_message(
|
|
db,
|
|
workspace_name=workspace_name,
|
|
session_name=session_name,
|
|
message_id=message_id,
|
|
)
|
|
if honcho_message is None:
|
|
raise ValueError("Message not found or does not belong to user")
|
|
if (
|
|
message.metadata is not None
|
|
): # Need to explicitly be there won't make it empty by default
|
|
honcho_message.h_metadata = message.metadata
|
|
await db.commit()
|
|
# await db.refresh(honcho_message)
|
|
return honcho_message
|
|
|
|
|
|
########################################################
|
|
# collection methods
|
|
########################################################
|
|
|
|
# Should be very similar to the session methods
|
|
|
|
|
|
async def get_collection(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
collection_name: str,
|
|
peer_name: str | None = None,
|
|
) -> models.Collection:
|
|
"""
|
|
Get a collection by name for a specific peer and workspace.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
peer_name: Name of the peer
|
|
collection_name: Name of the collection
|
|
|
|
Returns:
|
|
The collection if found
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the collection does not exist
|
|
"""
|
|
stmt = (
|
|
select(models.Collection)
|
|
.where(models.Collection.workspace_name == workspace_name)
|
|
.where(models.Collection.name == collection_name)
|
|
)
|
|
if peer_name:
|
|
stmt = stmt.where(models.Collection.peer_name == peer_name)
|
|
result = await db.execute(stmt)
|
|
collection = result.scalar_one_or_none()
|
|
if collection is None:
|
|
raise ResourceNotFoundException(
|
|
"Collection not found or does not belong to peer"
|
|
)
|
|
return collection
|
|
|
|
|
|
async def get_or_create_collection(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
collection_name: str,
|
|
peer_name: str | None = None,
|
|
) -> models.Collection:
|
|
try:
|
|
honcho_collection = await get_collection(
|
|
db, workspace_name, collection_name, peer_name
|
|
)
|
|
return honcho_collection
|
|
except ResourceNotFoundException:
|
|
honcho_collection = models.Collection(
|
|
workspace_name=workspace_name,
|
|
peer_name=peer_name,
|
|
name=collection_name,
|
|
)
|
|
db.add(honcho_collection)
|
|
await db.commit()
|
|
return honcho_collection
|
|
|
|
|
|
########################################################
|
|
# document methods
|
|
########################################################
|
|
|
|
|
|
async def query_documents(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
collection_name: str,
|
|
query: str,
|
|
filters: dict[str, Any] | None = None,
|
|
max_distance: float | None = None,
|
|
top_k: int = 5,
|
|
) -> Sequence[models.Document]:
|
|
# Using ModelClient for embeddings
|
|
try:
|
|
embedding_query = await embedding_client.embed(query)
|
|
except ValueError as e:
|
|
raise ValidationException(
|
|
f"Query exceeds maximum token limit of {settings.LLM.MAX_EMBEDDING_TOKENS}."
|
|
) from e
|
|
|
|
stmt = (
|
|
select(models.Document)
|
|
.where(models.Document.workspace_name == workspace_name)
|
|
.where(models.Document.peer_name == peer_name)
|
|
.where(models.Document.collection_name == collection_name)
|
|
# .limit(top_k)
|
|
)
|
|
if max_distance is not None:
|
|
stmt = stmt.where(
|
|
models.Document.embedding.cosine_distance(embedding_query) < max_distance
|
|
)
|
|
stmt = apply_filter(stmt, models.Document, filters)
|
|
stmt = stmt.limit(top_k).order_by(
|
|
models.Document.embedding.cosine_distance(embedding_query)
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def create_document(
|
|
db: AsyncSession,
|
|
document: schemas.DocumentCreate,
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
collection_name: str,
|
|
duplicate_threshold: float | None = None,
|
|
) -> models.Document:
|
|
"""
|
|
Embed text as a vector and create a document.
|
|
|
|
Args:
|
|
db: Database session
|
|
document: Document creation schema
|
|
workspace_name: Name of the workspace
|
|
peer_name: Name of the peer
|
|
collection_name: Name of the collection
|
|
|
|
Returns:
|
|
The created document
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the collection does not exist
|
|
ValidationException: If the document data is invalid
|
|
"""
|
|
|
|
# This will raise ResourceNotFoundException if collection not found
|
|
await get_collection(
|
|
db,
|
|
workspace_name=workspace_name,
|
|
collection_name=collection_name,
|
|
peer_name=peer_name,
|
|
)
|
|
|
|
# Using ModelClient for embeddings
|
|
embedding = await embedding_client.embed(document.content)
|
|
|
|
if duplicate_threshold is not None:
|
|
# Check if there are duplicates within the threshold
|
|
stmt = (
|
|
select(models.Document)
|
|
.where(models.Document.collection_name == collection_name)
|
|
.where(
|
|
models.Document.embedding.cosine_distance(embedding)
|
|
< duplicate_threshold
|
|
)
|
|
.order_by(models.Document.embedding.cosine_distance(embedding))
|
|
.limit(1)
|
|
)
|
|
result = await db.execute(stmt)
|
|
duplicate = result.scalar_one_or_none() # Get the closest match if any exist
|
|
if duplicate is not None:
|
|
logger.info(f"Duplicate found: {duplicate.content}. Ignoring new document.")
|
|
return duplicate
|
|
|
|
honcho_document = models.Document(
|
|
workspace_name=workspace_name,
|
|
peer_name=peer_name,
|
|
collection_name=collection_name,
|
|
content=document.content,
|
|
internal_metadata=document.metadata,
|
|
embedding=embedding,
|
|
)
|
|
db.add(honcho_document)
|
|
await db.commit()
|
|
await db.refresh(honcho_document)
|
|
return honcho_document
|
|
|
|
|
|
async def get_duplicate_documents(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
peer_name: str,
|
|
collection_name: str,
|
|
content: str,
|
|
similarity_threshold: float = 0.85,
|
|
) -> list[models.Document]:
|
|
"""Check if a document with similar content already exists in the collection.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
peer_name: Name of the peer
|
|
collection_name: Name of the collection
|
|
content: Document content to check for duplicates
|
|
similarity_threshold: Similarity threshold (0-1) for considering documents as duplicates
|
|
|
|
Returns:
|
|
List of documents that are similar to the provided content
|
|
"""
|
|
# Get embedding for the content
|
|
# Using ModelClient for embeddings
|
|
embedding = await embedding_client.embed(content)
|
|
|
|
# Find documents with similar embeddings
|
|
stmt = (
|
|
select(models.Document)
|
|
.where(models.Document.workspace_name == workspace_name)
|
|
.where(models.Document.peer_name == peer_name)
|
|
.where(models.Document.collection_name == collection_name)
|
|
.where(
|
|
models.Document.embedding.cosine_distance(embedding)
|
|
< (1 - similarity_threshold)
|
|
) # Convert similarity to distance
|
|
.order_by(models.Document.embedding.cosine_distance(embedding))
|
|
)
|
|
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all()) # Convert to list to match the return type
|
|
|
|
|
|
########################################################
|
|
# deriver queue methods
|
|
########################################################
|
|
|
|
|
|
async def get_deriver_status(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
peer_name: str | None = None,
|
|
session_name: str | None = None,
|
|
include_sender: bool = False,
|
|
) -> schemas.DeriverStatus:
|
|
"""
|
|
Get the deriver processing status, optionally filtered by peer and/or session.
|
|
|
|
Args:
|
|
db: Database session
|
|
workspace_name: Name of the workspace
|
|
peer_name: Optional name of the peer to filter by
|
|
session_name: Optional session name to filter by
|
|
include_sender: Whether to include work units where peer is the sender
|
|
|
|
Returns:
|
|
DeriverStatus: Schema containing processing status
|
|
|
|
Raises:
|
|
ValueError: If neither peer_name nor session_name is provided
|
|
"""
|
|
if (peer_name is None or peer_name == "") and (
|
|
session_name is None or session_name == ""
|
|
):
|
|
raise ValueError("At least one of peer_name or session_name must be provided")
|
|
|
|
# Normalize empty strings to None for consistent handling
|
|
normalized_peer_name = peer_name if peer_name else None
|
|
normalized_session_name = session_name if session_name else None
|
|
|
|
stmt = _build_queue_status_query(
|
|
workspace_name, normalized_peer_name, normalized_session_name, include_sender
|
|
)
|
|
result = await db.execute(stmt)
|
|
rows = result.fetchall()
|
|
|
|
counts = _process_queue_rows(rows)
|
|
return _build_status_response(peer_name, session_name, counts)
|
|
|
|
|
|
def _build_queue_status_query(
|
|
workspace_name: str,
|
|
peer_name: str | None,
|
|
session_name: str | None,
|
|
include_sender: bool,
|
|
) -> Select[Any]:
|
|
"""Build SQL query for queue status with validation and aggregation."""
|
|
from sqlalchemy import case, func
|
|
|
|
sender_name_expr = models.QueueItem.payload["sender_name"].astext
|
|
target_name_expr = models.QueueItem.payload["target_name"].astext
|
|
task_type_expr = models.QueueItem.payload["task_type"].astext
|
|
|
|
# Define conditions for cleaner window functions
|
|
is_completed = models.QueueItem.processed
|
|
is_in_progress = (~models.QueueItem.processed) & (
|
|
models.ActiveQueueSession.id.isnot(None)
|
|
)
|
|
is_pending = (~models.QueueItem.processed) & (
|
|
models.ActiveQueueSession.id.is_(None)
|
|
)
|
|
|
|
# Use window functions to calculate totals and per-session counts in SQL
|
|
stmt = select(
|
|
models.QueueItem.session_id,
|
|
# Overall totals using window functions
|
|
func.count().over().label("total"),
|
|
func.count(case((is_completed, 1))).over().label("completed"),
|
|
func.count(case((is_in_progress, 1))).over().label("in_progress"),
|
|
func.count(case((is_pending, 1))).over().label("pending"),
|
|
# Per-session totals using partitioned window functions
|
|
func.count()
|
|
.over(partition_by=models.QueueItem.session_id)
|
|
.label("session_total"),
|
|
func.count(case((is_completed, 1)))
|
|
.over(partition_by=models.QueueItem.session_id)
|
|
.label("session_completed"),
|
|
func.count(case((is_in_progress, 1)))
|
|
.over(partition_by=models.QueueItem.session_id)
|
|
.label("session_in_progress"),
|
|
func.count(case((is_pending, 1)))
|
|
.over(partition_by=models.QueueItem.session_id)
|
|
.label("session_pending"),
|
|
).select_from(models.QueueItem)
|
|
|
|
stmt = stmt.outerjoin(
|
|
models.ActiveQueueSession,
|
|
(models.QueueItem.session_id == models.ActiveQueueSession.session_id)
|
|
& (sender_name_expr == models.ActiveQueueSession.sender_name)
|
|
& (target_name_expr == models.ActiveQueueSession.target_name)
|
|
& (task_type_expr == models.ActiveQueueSession.task_type),
|
|
)
|
|
|
|
if peer_name is not None:
|
|
stmt = stmt.outerjoin(
|
|
models.Peer,
|
|
(models.Peer.name == peer_name)
|
|
& (models.Peer.workspace_name == workspace_name),
|
|
)
|
|
|
|
if session_name is not None:
|
|
stmt = stmt.outerjoin(
|
|
models.Session,
|
|
(models.Session.name == session_name)
|
|
& (models.Session.workspace_name == workspace_name),
|
|
)
|
|
stmt = stmt.where(models.QueueItem.session_id == models.Session.id)
|
|
|
|
if peer_name is not None:
|
|
if include_sender:
|
|
from sqlalchemy import or_
|
|
|
|
stmt = stmt.where(
|
|
or_(
|
|
target_name_expr == peer_name,
|
|
sender_name_expr == peer_name,
|
|
)
|
|
)
|
|
else:
|
|
stmt = stmt.where(target_name_expr == peer_name)
|
|
|
|
return stmt
|
|
|
|
|
|
def _process_queue_rows(rows: Sequence[Row[Any]]) -> schemas.QueueCounts:
|
|
"""Process query results that already contain aggregated counts."""
|
|
if not rows:
|
|
return schemas.QueueCounts(
|
|
total=0,
|
|
completed=0,
|
|
in_progress=0,
|
|
pending=0,
|
|
sessions={},
|
|
)
|
|
|
|
# Since we're using window functions, all rows have the same overall totals
|
|
# We just need the first row for overall counts
|
|
first_row = rows[0]
|
|
|
|
# Build sessions dictionary from unique session_ids
|
|
sessions: dict[str, schemas.SessionCounts] = {}
|
|
seen_sessions: set[str] = set()
|
|
|
|
for row in rows:
|
|
if row.session_id and row.session_id not in seen_sessions:
|
|
sessions[row.session_id] = schemas.SessionCounts(
|
|
completed=row.session_completed,
|
|
in_progress=row.session_in_progress,
|
|
pending=row.session_pending,
|
|
)
|
|
seen_sessions.add(row.session_id)
|
|
|
|
return schemas.QueueCounts(
|
|
total=first_row.total,
|
|
completed=first_row.completed,
|
|
in_progress=first_row.in_progress,
|
|
pending=first_row.pending,
|
|
sessions=sessions,
|
|
)
|
|
|
|
|
|
def _build_status_response(
|
|
peer_name: str | None, session_name: str | None, counts: schemas.QueueCounts
|
|
) -> schemas.DeriverStatus:
|
|
"""Build the final response object."""
|
|
|
|
if session_name:
|
|
return schemas.DeriverStatus(
|
|
session_id=session_name,
|
|
peer_id=peer_name,
|
|
total_work_units=counts.total,
|
|
completed_work_units=counts.completed,
|
|
in_progress_work_units=counts.in_progress,
|
|
pending_work_units=counts.pending,
|
|
)
|
|
|
|
sessions: dict[str, schemas.DeriverStatus] = {}
|
|
for session_id, data in counts.sessions.items():
|
|
total = data.completed + data.in_progress + data.pending
|
|
sessions[session_id] = schemas.DeriverStatus(
|
|
peer_id=peer_name,
|
|
session_id=session_id,
|
|
total_work_units=total,
|
|
completed_work_units=data.completed,
|
|
in_progress_work_units=data.in_progress,
|
|
pending_work_units=data.pending,
|
|
)
|
|
|
|
return schemas.DeriverStatus(
|
|
sessions=sessions if sessions else None,
|
|
peer_id=peer_name,
|
|
total_work_units=counts.total,
|
|
completed_work_units=counts.completed,
|
|
in_progress_work_units=counts.in_progress,
|
|
pending_work_units=counts.pending,
|
|
)
|
|
|
|
|
|
def construct_collection_name(peer_name: str, target_name: str) -> str:
|
|
return f"{peer_name}_{target_name}"
|