v0.0.13 Ability to Clone Sessions (#77)

* feat(storage) Add a route to clone sessions

* fix(storage) Add additional test case and fix case conventions for query parameters in clone method

* fix(storage) make case conventions consistent and documentation updates

* fix(docs) Update docs with newest routes

* fix(storage) Add commit to clone method
This commit is contained in:
Vineeth Voruganti 2024-11-07 12:10:09 -05:00 committed by GitHub
parent 05a57cffbd
commit 17ff3804ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 4767 additions and 7616 deletions

View File

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.0.13] — 2024-11-07
### Added
- Ability to clone session for a user to achieve more [loom-like](https://github.com/socketteer/loom/) behavior
## [0.0.12] — 2024-10-21
### Added

View File

@ -1,6 +1,6 @@
# 🫡 Honcho
![Static Badge](https://img.shields.io/badge/Version-0.0.12-blue)
![Static Badge](https://img.shields.io/badge/Version-0.0.13-blue)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs)
[![arXiv](https://img.shields.io/badge/arXiv-2310.06983-b31b1b.svg)](https://arxiv.org/abs/2310.06983)
![GitHub License](https://img.shields.io/github/license/plastic-labs/honcho)

View File

@ -0,0 +1,3 @@
---
openapi: get /v1/apps/{app_id}/users/{user_id}/sessions/{session_id}/clone
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v1/apps/{app_id}/users/{user_id}/sessions/{session_id}/chat/stream
---

View File

@ -109,7 +109,8 @@
"api-reference/endpoint/sessions/update-session",
"api-reference/endpoint/sessions/delete-session",
"api-reference/endpoint/sessions/chat",
"api-reference/endpoint/sessions/chat-stream"
"api-reference/endpoint/sessions/chat-stream",
"api-reference/endpoint/sessions/clone-session"
]
},
{
@ -159,7 +160,7 @@
"github": "https://github.com/plastic-labs",
"linkedin": "https://www.linkedin.com/company/plasticlabs"
},
"openapi": ["/openapi.json"],
"openapi": ["/openapi.yml"],
"analytics": {
"posthog": {
"apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4273
docs/openapi.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"main": ".pnp.js",
"scripts": {
"dev": "mintlify dev",
"openapi": "npx @mintlify/scraping openapi-file openapi.json -o api-reference/endpoint",
"openapi": "npx @mintlify/scraping openapi-file openapi.yml -o api-reference/endpoint",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "0.0.12"
version = "0.0.13"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},

View File

@ -4,9 +4,10 @@ from typing import Optional
from dotenv import load_dotenv
from openai import OpenAI
from sqlalchemy import Select, select
from sqlalchemy import Select, cast, insert, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger
from . import models, schemas
@ -269,6 +270,126 @@ async def delete_session(
return True
async def clone_session(
db: AsyncSession,
app_id: str,
user_id: str,
original_session_id: str,
cutoff_message_id: Optional[str] = None,
deep_copy: bool = True,
) -> 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
app_id: ID of the app the target session is in
user_id: ID of the user the target session belongs to
original_session_id: ID 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)
.join(models.User, models.User.public_id == models.Session.user_id)
.where(models.Session.public_id == original_session_id)
.where(models.Session.user_id == user_id)
.where(models.User.app_id == app_id)
)
original_session = await db.scalar(stmt)
if not original_session:
raise ValueError("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_id == original_session_id,
)
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(
user_id=original_session.user_id,
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_id == original_session_id
)
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_id": new_session.public_id,
"content": message.content,
"is_user": message.is_user,
"h_metadata": message.h_metadata,
}
for message in messages_to_clone
]
stmt = insert(models.Message).returning(models.Message.public_id)
result = await db.execute(stmt, new_messages)
new_message_ids = result.scalars().all()
# Create mapping of old to new message IDs
message_id_map = dict(
zip([message.public_id for message in messages_to_clone], new_message_ids)
)
# Handle metamessages if deep copy is requested
if deep_copy and message_id_map:
# Fetch all metamessages in a single query
stmt = select(models.Metamessage).where(
models.Metamessage.message_id.in_(message_id_map.keys())
)
metamessages_result = await db.scalars(stmt)
metamessages = metamessages_result.all()
if metamessages:
# Prepare bulk insert data for metamessages
new_metamessages = [
{
"message_id": message_id_map[meta.message_id],
"metamessage_type": meta.metamessage_type,
"content": meta.content,
"h_metadata": meta.h_metadata,
}
for meta in metamessages
]
# Bulk insert metamessages using modern insert syntax
stmt = insert(models.Metamessage)
await db.execute(stmt, new_metamessages)
await db.commit()
return new_session
########################################################
# Message Methods
########################################################

View File

@ -47,7 +47,7 @@ app = FastAPI(
summary="An API for adding personalization to AI Apps",
description="""This API is used to store data and get insights about users for AI
applications""",
version="0.0.12",
version="0.0.13",
contact={
"name": "Plastic Labs",
"url": "https://plasticlabs.ai",

View File

@ -215,3 +215,22 @@ async def get_chat_stream(
return StreamingResponse(
content=parse_stream(), media_type="text/event-stream", status_code=200
)
@router.get("/{session_id}/clone", response_model=schemas.Session)
async def clone_session(
app_id: str,
user_id: str,
session_id: str,
db=db,
message_id: Optional[str] = None,
deep_copy: bool = False,
):
return await crud.clone_session(
db,
app_id=app_id,
user_id=user_id,
original_session_id=session_id,
cutoff_message_id=message_id,
deep_copy=deep_copy,
)

View File

@ -80,25 +80,25 @@ async def test_get_metamessages(client, db_session, sample_data):
test_metamessage_1 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_2 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_3 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_4 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type_2",
)
db_session.add(test_metamessage_1)
@ -153,25 +153,25 @@ async def test_get_metamessage_by_user(client, db_session, sample_data):
test_metamessage_1 = models.Metamessage(
message_id=test_message_1.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_2 = models.Metamessage(
message_id=test_message_2.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_3 = models.Metamessage(
message_id=test_message_3.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_4 = models.Metamessage(
message_id=test_message_3.public_id,
content="Test Metamessage",
metadata={},
h_metadata={},
metamessage_type="test_type_2",
)
db_session.add(test_metamessage_1)
@ -186,9 +186,6 @@ async def test_get_metamessage_by_user(client, db_session, sample_data):
)
assert response.status_code == 200
data = response.json()
print("=======")
print(data)
print("=======")
assert len(data["items"]) > 0
assert len(data["items"]) == 3
assert data["items"][0]["content"] == "Test Metamessage"

View File

@ -59,7 +59,7 @@ async def test_get_sessions(client, db_session, sample_data):
async def test_empty_update_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(user_id=test_user.public_id, metadata={})
test_session = models.Session(user_id=test_user.public_id, h_metadata={})
db_session.add(test_session)
await db_session.commit()
@ -75,7 +75,7 @@ async def test_update_delete_metadata(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(
user_id=test_user.public_id, metadata={"default": "value"}
user_id=test_user.public_id, h_metadata={"default": "value"}
)
db_session.add(test_session)
await db_session.commit()
@ -93,7 +93,7 @@ async def test_update_delete_metadata(client, db_session, sample_data):
async def test_update_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(user_id=test_user.public_id, metadata={})
test_session = models.Session(user_id=test_user.public_id, h_metadata={})
db_session.add(test_session)
await db_session.commit()
@ -110,7 +110,7 @@ async def test_update_session(client, db_session, sample_data):
async def test_delete_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(user_id=test_user.public_id, metadata={})
test_session = models.Session(user_id=test_user.public_id, h_metadata={})
db_session.add(test_session)
await db_session.commit()
response = client.delete(
@ -122,3 +122,324 @@ async def test_delete_session(client, db_session, sample_data):
)
data = response.json()
assert data["is_active"] is False
@pytest.mark.asyncio
async def test_clone_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(
user_id=test_user.public_id, h_metadata={"test": "key"}
)
db_session.add(test_session)
await db_session.commit()
test_message = models.Message(
session_id=test_session.public_id,
content="Test message",
is_user=True,
h_metadata={"key": "value"},
)
test_message2 = models.Message(
session_id=test_session.public_id,
content="Test message 2",
is_user=True,
h_metadata={"key": "value2"},
)
db_session.add(test_message)
db_session.add(test_message2)
await db_session.commit()
response = client.get(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/clone",
)
assert response.status_code == 200
data = response.json()
assert data["metadata"] == {"test": "key"}
print(data)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{data['id']}/messages/list",
json={},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
assert len(data["items"]) == 2
assert data["items"][0]["content"] == "Test message"
assert data["items"][0]["is_user"] is True
assert data["items"][0]["metadata"] == {"key": "value"}
assert data["items"][1]["content"] == "Test message 2"
assert data["items"][1]["is_user"] is True
assert data["items"][1]["metadata"] == {"key": "value2"}
@pytest.mark.asyncio
async def test_partial_clone_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(
user_id=test_user.public_id, h_metadata={"test": "key"}
)
db_session.add(test_session)
await db_session.commit()
test_message = models.Message(
session_id=test_session.public_id,
content="Test message",
is_user=True,
h_metadata={"key": "value"},
)
test_message2 = models.Message(
session_id=test_session.public_id,
content="Test message 2",
is_user=True,
h_metadata={"key": "value2"},
)
test_message3 = models.Message(
session_id=test_session.public_id,
content="Test message 2",
is_user=True,
h_metadata={"key": "value2"},
)
db_session.add(test_message)
db_session.add(test_message2)
db_session.add(test_message3)
await db_session.commit()
response = client.get(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/clone?message_id={test_message2.public_id}",
)
assert response.status_code == 200
data = response.json()
assert data["metadata"] == {"test": "key"}
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{data['id']}/messages/list",
json={},
)
data = response.json()
assert len(data["items"]) > 0
assert len(data["items"]) == 2
assert data["items"][0]["content"] == "Test message"
assert data["items"][0]["is_user"] is True
assert data["items"][0]["metadata"] == {"key": "value"}
assert data["items"][1]["content"] == "Test message 2"
assert data["items"][1]["is_user"] is True
assert data["items"][1]["metadata"] == {"key": "value2"}
@pytest.mark.asyncio
async def test_deep_clone_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(
user_id=test_user.public_id, h_metadata={"test": "key"}
)
db_session.add(test_session)
await db_session.commit()
test_message = models.Message(
session_id=test_session.public_id,
content="Test message",
is_user=True,
h_metadata={"key": "value"},
)
test_message2 = models.Message(
session_id=test_session.public_id,
content="Test message 2",
is_user=True,
h_metadata={"key": "value2"},
)
db_session.add(test_message)
db_session.add(test_message2)
await db_session.commit()
test_metamessage_1 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage 1",
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_2 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage 2",
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_3 = models.Metamessage(
message_id=test_message2.public_id,
content="Test Metamessage 3",
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_4 = models.Metamessage(
message_id=test_message2.public_id,
content="Test Metamessage 4",
h_metadata={},
metamessage_type="test_type_2",
)
db_session.add(test_metamessage_1)
db_session.add(test_metamessage_2)
db_session.add(test_metamessage_3)
db_session.add(test_metamessage_4)
await db_session.commit()
response = client.get(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/clone?deep_copy=true",
)
assert response.status_code == 200
data = response.json()
assert data["metadata"] == {"test": "key"}
cloned_session_id = data["id"]
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{cloned_session_id}/messages/list",
json={},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
assert len(data["items"]) == 2
assert data["items"][0]["content"] == "Test message"
assert data["items"][0]["is_user"] is True
assert data["items"][0]["metadata"] == {"key": "value"}
assert data["items"][1]["content"] == "Test message 2"
assert data["items"][1]["is_user"] is True
assert data["items"][1]["metadata"] == {"key": "value2"}
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{cloned_session_id}/metamessages/list",
json={},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) > 0
assert len(data["items"]) == 4
assert data["items"][0]["content"] == "Test Metamessage 1"
assert data["items"][0]["metamessage_type"] == "test_type"
assert data["items"][0]["metadata"] == {}
assert data["items"][1]["content"] == "Test Metamessage 2"
assert data["items"][1]["metamessage_type"] == "test_type"
assert data["items"][1]["metadata"] == {}
assert data["items"][2]["content"] == "Test Metamessage 3"
assert data["items"][2]["metamessage_type"] == "test_type"
assert data["items"][2]["metadata"] == {}
assert data["items"][3]["content"] == "Test Metamessage 4"
assert data["items"][3]["metamessage_type"] == "test_type_2"
assert data["items"][3]["metadata"] == {}
@pytest.mark.asyncio
async def test_partial_deep_clone_session(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(
user_id=test_user.public_id, h_metadata={"test": "key"}
)
db_session.add(test_session)
await db_session.commit()
test_message = models.Message(
session_id=test_session.public_id,
content="Test message",
is_user=True,
h_metadata={"key": "value"},
)
test_message2 = models.Message(
session_id=test_session.public_id,
content="Test message 2",
is_user=True,
h_metadata={"key": "value2"},
)
db_session.add(test_message)
db_session.add(test_message2)
await db_session.commit()
test_metamessage_1 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage 1",
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_2 = models.Metamessage(
message_id=test_message.public_id,
content="Test Metamessage 2",
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_3 = models.Metamessage(
message_id=test_message2.public_id,
content="Test Metamessage 3",
h_metadata={},
metamessage_type="test_type",
)
test_metamessage_4 = models.Metamessage(
message_id=test_message2.public_id,
content="Test Metamessage 4",
h_metadata={},
metamessage_type="test_type_2",
)
db_session.add(test_metamessage_1)
db_session.add(test_metamessage_2)
db_session.add(test_metamessage_3)
db_session.add(test_metamessage_4)
await db_session.commit()
response = client.get(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/clone?deep_copy=true&message_id={test_message.public_id}",
)
assert response.status_code == 200
data = response.json()
assert data["metadata"] == {"test": "key"}
cloned_session_id = data["id"]
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{cloned_session_id}/messages/list",
json={},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
assert len(data["items"]) == 1
assert data["items"][0]["content"] == "Test message"
assert data["items"][0]["is_user"] is True
assert data["items"][0]["metadata"] == {"key": "value"}
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{cloned_session_id}/metamessages/list",
json={},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) > 0
assert len(data["items"]) == 2
assert data["items"][0]["content"] == "Test Metamessage 1"
assert data["items"][0]["metamessage_type"] == "test_type"
assert data["items"][0]["metadata"] == {}
assert data["items"][1]["content"] == "Test Metamessage 2"
assert data["items"][1]["metamessage_type"] == "test_type"
assert data["items"][1]["metadata"] == {}

View File

@ -425,7 +425,7 @@ wheels = [
[[package]]
name = "honcho"
version = "0.0.12"
version = "0.0.13"
source = { virtual = "." }
dependencies = [
{ name = "anthropic" },